Upload via GUI (267 Dateien)
This commit is contained in:
@@ -8,6 +8,7 @@ $fieldTypes = [
|
||||
'text' => 'Einzeiliger Text',
|
||||
'textarea' => 'Mehrzeiliger Text',
|
||||
'select' => 'Dropdown-Auswahl',
|
||||
'radio' => 'Radio-Auswahl (Punkte)',
|
||||
'checkbox' => 'Checkbox (Ja/Nein)',
|
||||
'number' => 'Zahl',
|
||||
'date' => 'Datum',
|
||||
@@ -25,20 +26,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$isRequired = !empty($_POST['is_required']) ? 1 : 0;
|
||||
$isActive = !empty($_POST['is_active']) ? 1 : 0;
|
||||
$sort = (int) ($_POST['sort_order'] ?? 0);
|
||||
$radioInline = !empty($_POST['radio_inline']) ? 1 : 0;
|
||||
$helpText = form_help_compose((string) ($_POST['help_text'] ?? ''), (string) ($_POST['link_url'] ?? ''), (string) ($_POST['link_word'] ?? ''), $label);
|
||||
|
||||
if ($label === '') $errors[] = 'Bitte eine Bezeichnung angeben.';
|
||||
if ($type === 'select' && $optionsRaw === '') $errors[] = 'Bitte mindestens eine Option angeben (eine pro Zeile).';
|
||||
if (in_array($type, ['select', 'radio'], true) && $optionsRaw === '') $errors[] = 'Bitte mindestens eine Option angeben (eine pro Zeile).';
|
||||
|
||||
if (!$errors) {
|
||||
if ($action === 'create') {
|
||||
$pdo->prepare('INSERT INTO custom_fields (label, field_type, options, help_topic_id, is_required, is_active, sort_order, created_at) VALUES (?,?,?,?,?,?,?,?)')
|
||||
->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, db_now()]);
|
||||
$pdo->prepare('INSERT INTO custom_fields (label, field_type, options, help_topic_id, is_required, is_active, sort_order, radio_inline, help_text, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, $radioInline, $helpText ?: null, db_now()]);
|
||||
audit_log($admin['id'], "Ticket-Feld erstellt: {$label}");
|
||||
flash('success', 'Feld erstellt.');
|
||||
} else {
|
||||
$id = (int) $_POST['id'];
|
||||
$pdo->prepare('UPDATE custom_fields SET label=?, field_type=?, options=?, help_topic_id=?, is_required=?, is_active=?, sort_order=? WHERE id=?')
|
||||
->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, $id]);
|
||||
$pdo->prepare('UPDATE custom_fields SET label=?, field_type=?, options=?, help_topic_id=?, is_required=?, is_active=?, sort_order=?, radio_inline=?, help_text=? WHERE id=?')
|
||||
->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, $radioInline, $helpText ?: null, $id]);
|
||||
audit_log($admin['id'], "Ticket-Feld aktualisiert: {$label}");
|
||||
flash('success', 'Feld aktualisiert.');
|
||||
}
|
||||
@@ -61,7 +64,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$fields = $pdo->query(
|
||||
'SELECT cf.*, ht.name AS help_topic_name FROM custom_fields cf
|
||||
LEFT JOIN help_topics ht ON ht.id = cf.help_topic_id
|
||||
ORDER BY cf.sort_order, cf.label'
|
||||
WHERE cf.department_id IS NULL ORDER BY cf.sort_order, cf.label'
|
||||
)->fetchAll();
|
||||
$helpTopics = $pdo->query('SELECT * FROM help_topics ORDER BY name')->fetchAll();
|
||||
|
||||
@@ -128,17 +131,36 @@ require __DIR__ . '/_header.php';
|
||||
<input type="text" id="label" name="label" value="<?= e($editField['label'] ?? '') ?>" placeholder="z. B. Vertragsnummer" required>
|
||||
|
||||
<label for="field_type">Feldtyp</label>
|
||||
<select name="field_type" id="field_type" onchange="document.getElementById('options-row').style.display = this.value==='select' ? 'block' : 'none'">
|
||||
<select name="field_type" id="field_type" onchange="document.getElementById('options-row').style.display = (this.value==='select' || this.value==='radio') ? 'block' : 'none'; document.getElementById('radio-row').style.display = this.value==='radio' ? 'block' : 'none'">
|
||||
<?php foreach ($fieldTypes as $val => $lbl): ?>
|
||||
<option value="<?= $val ?>" <?= ($editField['field_type'] ?? 'text') === $val ? 'selected' : '' ?>><?= e($lbl) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<div id="options-row" style="display:<?= ($editField['field_type'] ?? '') === 'select' ? 'block' : 'none' ?>">
|
||||
<div id="options-row" style="display:<?= in_array($editField['field_type'] ?? '', ['select', 'radio'], true) ? 'block' : 'none' ?>">
|
||||
<label for="options">Optionen (eine pro Zeile)</label>
|
||||
<textarea id="options" name="options" placeholder="Option 1 Option 2 Option 3"><?= e($editField['options'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<?php $__rt = $editField['field_type'] ?? ''; $__ri = $editField['radio_inline'] ?? 0; ?>
|
||||
<div id="radio-row" style="display:<?= ($__rt === 'radio') ? 'block' : 'none' ?>">
|
||||
<label for="radio_inline">Anordnung der Optionen</label>
|
||||
<select id="radio_inline" name="radio_inline">
|
||||
<option value="0" <?= empty($__ri) ? 'selected' : '' ?>>Untereinander</option>
|
||||
<option value="1" <?= !empty($__ri) ? 'selected' : '' ?>>Nebeneinander</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<?php $__hStored = $editField['help_text'] ?? ''; ?>
|
||||
<?php [$__hPlain, $__hUrl, $__hWord] = form_help_split($__hStored); ?>
|
||||
<label for="help_text">Hinweistext (optional)</label>
|
||||
<textarea id="help_text" name="help_text" rows="3" placeholder="z. B. Ich habe die Nutzerbedingungen gelesen und akzeptiert."><?= e($__hPlain) ?></textarea>
|
||||
<div class="field-row">
|
||||
<div><label for="link_word">Verlinkter Teil des Textes</label><input type="text" id="link_word" name="link_word" value="<?= e($__hWord) ?>" placeholder="z. B. Nutzerbedingungen"></div>
|
||||
<div><label for="link_url">Link-Adresse</label><input type="text" id="link_url" name="link_url" value="<?= e($__hUrl) ?>" placeholder="https://…"></div>
|
||||
</div>
|
||||
<p class="muted" style="font-size:.78rem;margin:3px 0 0">Bei einer <strong>Checkbox</strong> steht der Text neben dem Kästchen, bei anderen Feldern unter dem Feld. Das Wort aus „Verlinkter Teil“ wird im Text zum Link auf die Adresse. Eine Checkbox als <strong>Pflichtfeld</strong> muss angehakt werden.</p>
|
||||
|
||||
<label for="help_topic_id">Gilt für Hilfe-Thema</label>
|
||||
<select name="help_topic_id" id="help_topic_id">
|
||||
<option value="">Alle Themen (oder Formular ohne Themenauswahl)</option>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_admin_or_permission('manage_departments');
|
||||
$pdo = get_db();
|
||||
|
||||
$deptId = (int) ($_GET['dept'] ?? $_POST['dept'] ?? 0);
|
||||
$stmt = $pdo->prepare('SELECT * FROM departments WHERE id = ?');
|
||||
$stmt->execute([$deptId]);
|
||||
$dept = $stmt->fetch();
|
||||
if (!$dept) {
|
||||
flash('error', 'Abteilung nicht gefunden.');
|
||||
redirect('/staff/departments.php');
|
||||
}
|
||||
|
||||
$fieldTypes = [
|
||||
'text' => 'Einzeiliger Text',
|
||||
'textarea' => 'Mehrzeiliger Text',
|
||||
'select' => 'Dropdown-Auswahl',
|
||||
'radio' => 'Radio-Auswahl (Punkte)',
|
||||
'checkbox' => 'Checkbox (Ja/Nein)',
|
||||
'number' => 'Zahl',
|
||||
'date' => 'Datum',
|
||||
];
|
||||
$errors = [];
|
||||
// Feldpositionen: Schlüssel => [Gruppe, Beschriftung]; "@"-Schlüssel stehen in custom_fields.form_section
|
||||
$positions = [
|
||||
'@topic_top' => ['Abschnitt 1 – Themenauswahl', 'vor der Themenauswahl'],
|
||||
'@topic' => ['Abschnitt 1 – Themenauswahl', 'nach der Themenauswahl'],
|
||||
'@contact_top' => ['Abschnitt 2 – Kontaktdaten', 'vor Name/E-Mail'],
|
||||
'@contact' => ['Abschnitt 2 – Kontaktdaten', 'nach Name/E-Mail'],
|
||||
'@details_top' => ['Abschnitt 3 – Betreff und Beschreibung', 'vor dem Betreff'],
|
||||
'@details_mid' => ['Abschnitt 3 – Betreff und Beschreibung', 'zwischen Betreff und Beschreibung'],
|
||||
'@details' => ['Abschnitt 3 – Betreff und Beschreibung', 'nach der Beschreibung'],
|
||||
];
|
||||
$self = '/staff/department_form.php?dept=' . $deptId;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'save_form') {
|
||||
$clip = fn(string $k, int $max) => mb_substr(trim((string) ($_POST[$k] ?? '')), 0, $max);
|
||||
$cfg = [
|
||||
'title' => $clip('title', 150),
|
||||
'contact_title' => $clip('contact_title', 100),
|
||||
'issue_title' => $clip('issue_title', 100),
|
||||
'details_title' => $clip('details_title', 100),
|
||||
'intro' => $clip('intro', 1000),
|
||||
'subject_show' => !empty($_POST['subject_show']),
|
||||
'subject_label' => $clip('subject_label', 100),
|
||||
'message_label' => $clip('message_label', 100),
|
||||
'message_show' => !empty($_POST['message_show']),
|
||||
'subject_width' => in_array($_POST['subject_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $_POST['subject_width'] : 'full',
|
||||
'message_width' => in_array($_POST['message_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $_POST['message_width'] : 'full',
|
||||
'message_hint' => $clip('message_hint', 200),
|
||||
'attachment_show' => !empty($_POST['attachment_show']),
|
||||
'submit_label' => $clip('submit_label', 60),
|
||||
];
|
||||
$pdo->prepare('UPDATE departments SET own_form = ?, form_config = ?, updated_at = ? WHERE id = ?')
|
||||
->execute([!empty($_POST['own_form']) ? 1 : 0, json_encode($cfg, JSON_UNESCAPED_UNICODE), db_now(), $deptId]);
|
||||
audit_log($staff['id'], 'Abteilungsformular gespeichert: ' . $dept['name']);
|
||||
flash('success', 'Formular gespeichert.');
|
||||
redirect($self);
|
||||
}
|
||||
|
||||
if ($action === 'save_field') {
|
||||
$fid = (int) ($_POST['id'] ?? 0);
|
||||
$label = trim((string) ($_POST['label'] ?? ''));
|
||||
$type = array_key_exists($_POST['field_type'] ?? '', $fieldTypes) ? $_POST['field_type'] : 'text';
|
||||
$options = trim((string) ($_POST['options'] ?? ''));
|
||||
$required = !empty($_POST['is_required']) ? 1 : 0;
|
||||
$active = !empty($_POST['is_active']) ? 1 : 0;
|
||||
$sort = (int) ($_POST['sort_order'] ?? 0);
|
||||
$radioInline = !empty($_POST['radio_inline']) ? 1 : 0;
|
||||
$helpText = form_help_compose((string) ($_POST['help_text'] ?? ''), (string) ($_POST['link_url'] ?? ''), (string) ($_POST['link_word'] ?? ''), $label);
|
||||
$width = in_array($_POST['form_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $_POST['form_width'] : null;
|
||||
$secPick = (string) ($_POST['form_section'] ?? '');
|
||||
$section = ($secPick === '' || isset($positions[$secPick])) ? $secPick : mb_substr(trim((string) ($_POST['form_section_custom'] ?? '')), 0, 100);
|
||||
if ($label === '') $errors[] = 'Bitte eine Bezeichnung angeben.';
|
||||
if (in_array($type, ['select', 'radio'], true) && $options === '') $errors[] = 'Bitte mindestens eine Option angeben (eine pro Zeile).';
|
||||
if (!$errors) {
|
||||
if ($fid) {
|
||||
$pdo->prepare('UPDATE custom_fields SET label=?, field_type=?, options=?, is_required=?, is_active=?, sort_order=?, form_section=?, form_width=?, radio_inline=?, help_text=? WHERE id=? AND department_id=?')
|
||||
->execute([$label, $type, $options ?: null, $required, $active, $sort, $section ?: null, $width, $radioInline, $helpText ?: null, $fid, $deptId]);
|
||||
} else {
|
||||
$pdo->prepare('INSERT INTO custom_fields (label, field_type, options, help_topic_id, department_id, is_required, is_active, sort_order, form_section, form_width, radio_inline, help_text, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute([$label, $type, $options ?: null, null, $deptId, $required, $active, $sort, $section ?: null, $width, $radioInline, $helpText ?: null, db_now()]);
|
||||
}
|
||||
audit_log($staff['id'], "Abteilungsfeld gespeichert ({$dept['name']}): {$label}");
|
||||
flash('success', 'Feld gespeichert.');
|
||||
redirect($self);
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete_field') {
|
||||
$pdo->prepare('DELETE FROM custom_fields WHERE id = ? AND department_id = ?')->execute([(int) ($_POST['id'] ?? 0), $deptId]);
|
||||
audit_log($staff['id'], "Abteilungsfeld gelöscht ({$dept['name']})");
|
||||
flash('success', 'Feld gelöscht (bereits erfasste Werte in Tickets bleiben erhalten).');
|
||||
redirect($self);
|
||||
}
|
||||
}
|
||||
|
||||
$cfg = json_decode((string) ($dept['form_config'] ?? ''), true);
|
||||
$cfg = is_array($cfg) ? $cfg : [];
|
||||
$cv = fn(string $k, string $def = '') => (string) ($cfg[$k] ?? $def);
|
||||
$flag = fn(string $k) => !array_key_exists($k, $cfg) || !empty($cfg[$k]);
|
||||
|
||||
$fieldsStmt = $pdo->prepare('SELECT * FROM custom_fields WHERE department_id = ? ORDER BY sort_order, label');
|
||||
$fieldsStmt->execute([$deptId]);
|
||||
$fields = $fieldsStmt->fetchAll();
|
||||
$editField = null;
|
||||
if (!empty($_GET['edit'])) {
|
||||
foreach ($fields as $f) if ((int) $f['id'] === (int) $_GET['edit']) $editField = $f;
|
||||
}
|
||||
|
||||
$pageTitle = 'Formular: ' . $dept['name'];
|
||||
$activePage = 'departments';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<p><a href="/staff/departments.php?edit=<?= $deptId ?>">← Zurück zur Abteilung</a></p>
|
||||
<h1>Ticket-Formular <span class="muted" style="font-size:1rem;font-weight:400">— <?= e($dept['name']) ?></span></h1>
|
||||
<p class="muted">Hier legst du fest, wie das Formular „Neues Ticket“ aussieht, wenn ein Kunde diese Abteilung wählt (direkt oder über ein Hilfe-Thema dieser Abteilung).</p>
|
||||
|
||||
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
|
||||
|
||||
<form method="post" class="card" style="margin-bottom:20px">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_form">
|
||||
<input type="hidden" name="dept" value="<?= $deptId ?>">
|
||||
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:0">
|
||||
<input type="checkbox" name="own_form" style="width:auto" <?= !empty($dept['own_form']) ? 'checked' : '' ?>>
|
||||
<strong>Eigenes Formular für diese Abteilung verwenden</strong>
|
||||
</label>
|
||||
<p class="muted" style="font-size:.82rem;margin:4px 0 12px">
|
||||
Ist das aktiv, erscheinen für diese Abteilung <strong>nur</strong> die unten festgelegten Felder. Die allgemeinen Ticket-Felder und die Felder der Hilfe-Themen werden dann nicht verwendet.
|
||||
Name und E-Mail-Adresse werden immer abgefragt, damit wir antworten können.
|
||||
</p>
|
||||
|
||||
<div class="field-row">
|
||||
<div><label for="title">Überschrift (optional)</label><input type="text" id="title" name="title" value="<?= e($cv('title')) ?>" placeholder="z. B. Störungsmeldung Technik"></div>
|
||||
<div><label for="submit_label">Text des Absende-Knopfs</label><input type="text" id="submit_label" name="submit_label" value="<?= e($cv('submit_label')) ?>" placeholder="Ticket erstellen"></div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div><label for="contact_title">Überschrift Abschnitt „Kontaktdaten“</label><input type="text" id="contact_title" name="contact_title" value="<?= e($cv('contact_title')) ?>" placeholder="Ihre Kontaktdaten"></div>
|
||||
<div><label for="issue_title">Überschrift Abschnitt 1 (Themenauswahl)</label><input type="text" id="issue_title" name="issue_title" value="<?= e($cv('issue_title')) ?>" placeholder="Ihr Anliegen"></div>
|
||||
</div>
|
||||
<label for="details_title">Überschrift Abschnitt „Betreff und Beschreibung“</label>
|
||||
<input type="text" id="details_title" name="details_title" value="<?= e($cv('details_title')) ?>" placeholder="Ihr Anliegen">
|
||||
<label for="intro">Einleitungstext (optional)</label>
|
||||
<textarea id="intro" name="intro" rows="2" placeholder="Wird oberhalb der Felder angezeigt"><?= e($cv('intro')) ?></textarea>
|
||||
|
||||
<h3>Standardfelder</h3>
|
||||
<label style="display:flex;align-items:center;gap:8px"><input type="checkbox" name="subject_show" style="width:auto" <?= $flag('subject_show') ? 'checked' : '' ?>> Betreff abfragen</label>
|
||||
<p class="muted" style="font-size:.78rem;margin:2px 0 8px">Ohne Betrefffeld wird der Betreff automatisch „Anfrage <?= e($dept['name']) ?>“.</p>
|
||||
<div class="field-row">
|
||||
<div><label for="subject_label">Bezeichnung Betreff</label><input type="text" id="subject_label" name="subject_label" value="<?= e($cv('subject_label')) ?>" placeholder="Betreff"></div>
|
||||
<div><label for="message_label">Bezeichnung Beschreibung</label><input type="text" id="message_label" name="message_label" value="<?= e($cv('message_label')) ?>" placeholder="Beschreibung"></div>
|
||||
</div>
|
||||
<?php $widthOpts = ['full' => 'Ganze Zeile', 'half' => 'Halbe Zeile', 'third' => 'Ein Drittel', 'twothirds' => 'Zwei Drittel']; ?>
|
||||
<div class="field-row">
|
||||
<div><label for="subject_width">Breite des Betreff-Feldes</label>
|
||||
<select id="subject_width" name="subject_width"><?php foreach ($widthOpts as $k => $l): ?><option value="<?= $k ?>" <?= $cv('subject_width', 'full') === $k ? 'selected' : '' ?>><?= e($l) ?></option><?php endforeach; ?></select></div>
|
||||
<div><label for="message_width">Breite des Beschreibungs-Feldes</label>
|
||||
<select id="message_width" name="message_width"><?php foreach ($widthOpts as $k => $l): ?><option value="<?= $k ?>" <?= $cv('message_width', 'full') === $k ? 'selected' : '' ?>><?= e($l) ?></option><?php endforeach; ?></select></div>
|
||||
</div>
|
||||
<p class="muted" style="font-size:.78rem;margin:2px 0 0">Mit „Halbe Zeile“ steht der Betreff neben einem benachbarten eigenen Feld (zum Beispiel einem Zahlenfeld direkt vor oder nach dem Betreff).</p>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:12px"><input type="checkbox" name="message_show" style="width:auto" <?= $flag('message_show') ? 'checked' : '' ?>> Beschreibung (großes Textfeld) abfragen</label>
|
||||
<p class="muted" style="font-size:.78rem;margin:2px 0 8px">Ohne Beschreibung wird der Text des Tickets aus deinen eigenen Feldern zusammengesetzt („Bezeichnung: Eingabe“).</p>
|
||||
<label for="message_hint">Platzhaltertext der Beschreibung</label>
|
||||
<input type="text" id="message_hint" name="message_hint" value="<?= e($cv('message_hint')) ?>" placeholder="Beschreiben Sie Ihr Anliegen so detailliert wie möglich…">
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:12px"><input type="checkbox" name="attachment_show" style="width:auto" <?= $flag('attachment_show') ? 'checked' : '' ?>> Datei-Anhang erlauben</label>
|
||||
|
||||
<div class="btn-row"><button type="submit" class="btn">Formular speichern</button></div>
|
||||
</form>
|
||||
|
||||
<div style="display:flex;gap:24px;align-items:flex-start;flex-wrap:wrap">
|
||||
<div style="flex:2;min-width:320px">
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0">Felder dieser Abteilung</h3>
|
||||
<table>
|
||||
<thead><tr><th>Reihenfolge</th><th>Abschnitt</th><th>Bezeichnung</th><th>Typ</th><th>Pflicht</th><th>Aktiv</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($fields as $f): ?>
|
||||
<tr>
|
||||
<td><?= (int) $f['sort_order'] ?></td>
|
||||
<td class="muted"><?php $fs = trim((string) ($f['form_section'] ?? '')); echo e($fs === '' ? 'Zusätzliche Angaben' : (isset($positions[$fs]) ? $positions[$fs][0] . ': ' . $positions[$fs][1] : $fs)); ?></td>
|
||||
<td><?= e($f['label']) ?></td>
|
||||
<td><?= e($fieldTypes[$f['field_type']] ?? $f['field_type']) ?></td>
|
||||
<td><?= $f['is_required'] ? '✓' : '–' ?></td>
|
||||
<td><?= $f['is_active'] ? '✓' : '–' ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="<?= e($self) ?>&edit=<?= (int) $f['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Feld wirklich löschen?');">
|
||||
<?= csrf_field() ?><input type="hidden" name="action" value="delete_field"><input type="hidden" name="dept" value="<?= $deptId ?>"><input type="hidden" name="id" value="<?= (int) $f['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$fields): ?><tr><td colspan="7" class="muted">Noch keine eigenen Felder. Rechts kannst du das erste anlegen.</td></tr><?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:300px">
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0"><?= $editField ? 'Feld bearbeiten' : 'Neues Feld' ?></h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_field">
|
||||
<input type="hidden" name="dept" value="<?= $deptId ?>">
|
||||
<?php if ($editField): ?><input type="hidden" name="id" value="<?= (int) $editField['id'] ?>"><?php endif; ?>
|
||||
<label for="label">Bezeichnung</label>
|
||||
<input type="text" id="label" name="label" value="<?= e($_POST['label'] ?? $editField['label'] ?? '') ?>" placeholder="z. B. Gerätenummer" required>
|
||||
<label for="field_type">Feldtyp</label>
|
||||
<select name="field_type" id="field_type" onchange="document.getElementById('options-row').style.display = (this.value==='select' || this.value==='radio') ? 'block' : 'none'; document.getElementById('radio-row').style.display = this.value==='radio' ? 'block' : 'none'">
|
||||
<?php foreach ($fieldTypes as $val => $lbl): ?>
|
||||
<option value="<?= $val ?>" <?= ($_POST['field_type'] ?? $editField['field_type'] ?? 'text') === $val ? 'selected' : '' ?>><?= e($lbl) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div id="options-row" style="display:<?= in_array($_POST['field_type'] ?? $editField['field_type'] ?? '', ['select', 'radio'], true) ? 'block' : 'none' ?>">
|
||||
<label for="options">Optionen (eine pro Zeile)</label>
|
||||
<textarea id="options" name="options" placeholder="Option 1 Option 2"><?= e($_POST['options'] ?? $editField['options'] ?? '') ?></textarea>
|
||||
</div>
|
||||
<?php
|
||||
$curSec = (string) ($_POST['form_section'] ?? ($editField['form_section'] ?? ''));
|
||||
$special = array_merge([''], array_keys($positions));
|
||||
$isCustom = $curSec !== '' && !in_array($curSec, $special, true);
|
||||
$customText = $_POST['form_section_custom'] ?? ($isCustom ? $curSec : '');
|
||||
$pick = $isCustom ? '__custom' : $curSec;
|
||||
?>
|
||||
<label for="form_section">Wo im Formular erscheint das Feld?</label>
|
||||
<select id="form_section" name="form_section" onchange="document.getElementById('custom-section-row').style.display = this.value==='__custom' ? 'block' : 'none'">
|
||||
<?php $lastGroup = ''; foreach ($positions as $key => [$grp, $lbl]):
|
||||
if ($grp !== $lastGroup) { if ($lastGroup !== '') echo '</optgroup>'; echo '<optgroup label="' . e($grp) . '">'; $lastGroup = $grp; } ?>
|
||||
<option value="<?= e($key) ?>" <?= $pick === $key ? 'selected' : '' ?>><?= e($lbl) ?></option>
|
||||
<?php endforeach; echo '</optgroup>'; ?>
|
||||
<option value="" <?= $pick === '' ? 'selected' : '' ?>>Zusätzliche Angaben (eigener Abschnitt danach)</option>
|
||||
<option value="__custom" <?= $pick === '__custom' ? 'selected' : '' ?>>Eigener Abschnitt mit eigenem Namen …</option>
|
||||
</select>
|
||||
<div id="custom-section-row" style="display:<?= $pick === '__custom' ? 'block' : 'none' ?>">
|
||||
<input type="text" name="form_section_custom" list="sections" value="<?= e($customText) ?>" placeholder="z. B. Abrechnung" style="margin-top:6px">
|
||||
<datalist id="sections">
|
||||
<?php foreach (array_unique(array_filter(array_map(fn($x) => trim((string) ($x['form_section'] ?? '')), $fields), fn($v) => $v !== '' && $v[0] !== '@')) as $sn): ?><option value="<?= e($sn) ?>"><?php endforeach; ?>
|
||||
</datalist>
|
||||
<p class="muted" style="font-size:.78rem;margin:3px 0 0">Felder mit gleichem Namen landen im selben Abschnitt.</p>
|
||||
</div>
|
||||
<?php $__rt = $_POST['field_type'] ?? $editField['field_type'] ?? ''; $__ri = $_POST['radio_inline'] ?? $editField['radio_inline'] ?? 0; ?>
|
||||
<div id="radio-row" style="display:<?= ($__rt === 'radio') ? 'block' : 'none' ?>">
|
||||
<label for="radio_inline">Anordnung der Optionen</label>
|
||||
<select id="radio_inline" name="radio_inline">
|
||||
<option value="0" <?= empty($__ri) ? 'selected' : '' ?>>Untereinander</option>
|
||||
<option value="1" <?= !empty($__ri) ? 'selected' : '' ?>>Nebeneinander</option>
|
||||
</select>
|
||||
</div>
|
||||
<?php $__hStored = $_POST ? form_help_compose((string) ($_POST['help_text'] ?? ''), (string) ($_POST['link_url'] ?? ''), (string) ($_POST['link_word'] ?? '')) : ($editField['help_text'] ?? ''); ?>
|
||||
<?php [$__hPlain, $__hUrl, $__hWord] = form_help_split($__hStored); ?>
|
||||
<label for="help_text">Hinweistext (optional)</label>
|
||||
<textarea id="help_text" name="help_text" rows="3" placeholder="z. B. Ich habe die Nutzerbedingungen gelesen und akzeptiert."><?= e($__hPlain) ?></textarea>
|
||||
<div class="field-row">
|
||||
<div><label for="link_word">Verlinkter Teil des Textes</label><input type="text" id="link_word" name="link_word" value="<?= e($__hWord) ?>" placeholder="z. B. Nutzerbedingungen"></div>
|
||||
<div><label for="link_url">Link-Adresse</label><input type="text" id="link_url" name="link_url" value="<?= e($__hUrl) ?>" placeholder="https://…"></div>
|
||||
</div>
|
||||
<p class="muted" style="font-size:.78rem;margin:3px 0 0">Bei einer <strong>Checkbox</strong> steht der Text neben dem Kästchen, bei anderen Feldern unter dem Feld. Das Wort aus „Verlinkter Teil“ wird im Text zum Link auf die Adresse. Eine Checkbox als <strong>Pflichtfeld</strong> muss angehakt werden.</p>
|
||||
<label for="form_width">Breite im Formular</label>
|
||||
<?php $curW = (string) ($_POST['form_width'] ?? ($editField['form_width'] ?? '')); ?>
|
||||
<select id="form_width" name="form_width">
|
||||
<option value="" <?= $curW === '' ? 'selected' : '' ?>>Ganze Zeile</option>
|
||||
<option value="half" <?= $curW === 'half' ? 'selected' : '' ?>>Halbe Zeile (zwei Felder nebeneinander)</option>
|
||||
<option value="third" <?= $curW === 'third' ? 'selected' : '' ?>>Ein Drittel (drei Felder nebeneinander)</option>
|
||||
<option value="twothirds" <?= $curW === 'twothirds' ? 'selected' : '' ?>>Zwei Drittel (mit einem Drittel-Feld)</option>
|
||||
</select>
|
||||
<p class="muted" style="font-size:.78rem;margin:3px 0 0">Aufeinanderfolgende Felder im selben Abschnitt, die zusammen in eine Zeile passen, stehen nebeneinander.</p>
|
||||
<label for="sort_order">Reihenfolge (kleinere Zahl zuerst)</label>
|
||||
<input type="text" id="sort_order" name="sort_order" value="<?= e((string) ($_POST['sort_order'] ?? $editField['sort_order'] ?? count($fields) * 10 + 10)) ?>">
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:14px"><input type="checkbox" name="is_required" style="width:auto" <?= !empty($editField['is_required']) ? 'checked' : '' ?>> Pflichtfeld</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:8px"><input type="checkbox" name="is_active" style="width:auto" <?= ($editField['is_active'] ?? 1) ? 'checked' : '' ?>> Aktiv</label>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn"><?= $editField ? 'Speichern' : 'Feld anlegen' ?></button>
|
||||
<?php if ($editField): ?><a href="<?= e($self) ?>" class="btn secondary">Abbrechen</a><?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -106,6 +106,7 @@ require __DIR__ . '/_header.php';
|
||||
<div style="display:flex;gap:4px;padding:10px 12px 0;border-bottom:1px solid var(--border)">
|
||||
<button type="button" class="tab-btn" data-tab="dept">Abteilung</button>
|
||||
<button type="button" class="tab-btn" data-tab="members">Mitglieder<?= $editDept ? ' (' . count($members) . ')' : '' ?></button>
|
||||
<?php if ($editDept): ?><a href="/staff/department_form.php?dept=<?= (int) $editDept['id'] ?>" class="tab-btn" style="text-decoration:none">Ticket-Formular<?= !empty($editDept['own_form']) ? ' ✓' : '' ?></a><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" data-pane="dept" style="padding:16px 20px">
|
||||
@@ -224,6 +225,7 @@ require __DIR__ . '/_header.php';
|
||||
<td class="muted" style="font-size:.82rem"><?= e($fmt($d['updated_at'] ?? null)) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="/staff/departments.php?edit=<?= (int) $d['id'] ?>">Bearbeiten</a> ·
|
||||
<a href="/staff/department_form.php?dept=<?= (int) $d['id'] ?>">Formular<?= !empty($d['own_form']) ? ' ✓' : '' ?></a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Abteilung wirklich löschen?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
|
||||
@@ -20,7 +20,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// Gleiche Fehlermeldung – verrät nicht ob die E-Mail bekannt ist
|
||||
$errors[] = 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten erneut.';
|
||||
} else {
|
||||
$stmt = get_db()->prepare('SELECT * FROM staff WHERE email = ? AND is_active = 1');
|
||||
$stmt = get_db()->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
|
||||
$stmt->execute([$email]);
|
||||
$staff = $stmt->fetch();
|
||||
|
||||
@@ -42,6 +42,7 @@ $pageTitle = 'Passwort vergessen';
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Passwort vergessen – Mitarbeiterbereich</title>
|
||||
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
|
||||
<?= site_icon_links() ?>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-wrap">
|
||||
@@ -59,7 +60,7 @@ $pageTitle = 'Passwort vergessen';
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<label for="email">E-Mail-Adresse</label>
|
||||
<input type="email" id="email" name="email" required autofocus>
|
||||
<input type="email" id="email" name="email" required autofocus autocapitalize="none" autocorrect="off" spellcheck="false">
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn" style="width:100%">Link anfordern</button>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ if ($editTopic) {
|
||||
$cfStmt = $pdo->prepare('SELECT * FROM custom_fields WHERE help_topic_id = ? ORDER BY sort_order, label');
|
||||
$cfStmt->execute([$editTopic['id']]);
|
||||
$topicFields = $cfStmt->fetchAll();
|
||||
$globalFields = $pdo->query('SELECT * FROM custom_fields WHERE help_topic_id IS NULL AND is_active = 1 ORDER BY sort_order, label')->fetchAll();
|
||||
$globalFields = $pdo->query('SELECT * FROM custom_fields WHERE help_topic_id IS NULL AND department_id IS NULL AND is_active = 1 ORDER BY sort_order, label')->fetchAll();
|
||||
}
|
||||
$sortMode = ($_GET['sort'] ?? 'alpha') === 'manual' ? 'manual' : 'alpha';
|
||||
$topics = $pdo->query(
|
||||
|
||||
@@ -31,11 +31,47 @@ function import_json(array $data): never
|
||||
function import_ajax_guard(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', (string) ($_POST['csrf_token'] ?? ''))) import_json(['ok' => false, 'error' => 'CSRF']);
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', (string) ($_POST['csrf_token'] ?? ''))) {
|
||||
// Ist eine Anfrage größer als post_max_size, verwirft PHP alle Formulardaten: dann fehlt auch das Token
|
||||
import_json(['ok' => false, 'error' => 'Die Sitzung ist abgelaufen oder der Upload-Teil war größer als der Server erlaubt (post_max_size ' . ini_get('post_max_size') . '). Bitte die Seite neu laden und erneut versuchen.']);
|
||||
}
|
||||
}
|
||||
|
||||
$stateDir = osticket_state_dir();
|
||||
|
||||
/** PHP-Größenangabe ("2M", "128K", "1G") in Bytes. */
|
||||
function import_ini_bytes(string $v): int
|
||||
{
|
||||
$v = trim($v);
|
||||
if ($v === '' || $v === '-1') return PHP_INT_MAX;
|
||||
$n = (float) $v;
|
||||
return (int) match (strtolower(substr($v, -1))) { 'g' => $n * 1073741824, 'm' => $n * 1048576, 'k' => $n * 1024, default => $n };
|
||||
}
|
||||
|
||||
/** Größe eines Upload-Teils: so groß wie der Server erlaubt (höchstens 5 MB, mit Reserve), damit der Upload auch bei kleinen Limits (z. B. 2 MB) klappt. */
|
||||
function import_chunk_size(): int
|
||||
{
|
||||
$limit = min(import_ini_bytes((string) ini_get('upload_max_filesize')), import_ini_bytes((string) ini_get('post_max_size')));
|
||||
$size = (int) ($limit * 0.8);
|
||||
$size = intdiv($size, 65536) * 65536;
|
||||
return max(131072, min(5 * 1048576, $size));
|
||||
}
|
||||
|
||||
/** Verständliche Meldung zu einem PHP-Upload-Fehlercode. */
|
||||
function import_upload_error(?array $file): string
|
||||
{
|
||||
$code = $file['error'] ?? UPLOAD_ERR_NO_FILE;
|
||||
$limit = ini_get('upload_max_filesize') . ' / post_max_size ' . ini_get('post_max_size');
|
||||
return match ($code) {
|
||||
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'Der Server lehnt den Upload-Teil ab, er ist größer als erlaubt (upload_max_filesize ' . $limit . '). Laden Sie die Seite neu und versuchen Sie es erneut, oder legen Sie die Datei per FTP in den DeskHub-Ordner (Option 1).',
|
||||
UPLOAD_ERR_PARTIAL => 'Der Upload-Teil kam nur unvollständig an (Verbindung unterbrochen). Bitte erneut versuchen.',
|
||||
UPLOAD_ERR_NO_FILE => 'Es kam kein Upload-Teil an.',
|
||||
UPLOAD_ERR_NO_TMP_DIR => 'Auf dem Server fehlt der temporäre Ordner für Uploads (upload_tmp_dir). Bitte den Hoster fragen oder die Datei per FTP hochladen (Option 1).',
|
||||
UPLOAD_ERR_CANT_WRITE => 'Der Server konnte den Upload-Teil nicht speichern (Speicherplatz oder Schreibrechte). Bitte die Datei per FTP hochladen (Option 1).',
|
||||
default => 'Upload fehlgeschlagen (PHP-Fehlercode ' . $code . ').',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Chunked Upload ─────────────────────────────────────────────────────────
|
||||
if (($_POST['action'] ?? '') === 'upload_chunk') {
|
||||
import_ajax_guard();
|
||||
@@ -43,10 +79,12 @@ if (($_POST['action'] ?? '') === 'upload_chunk') {
|
||||
$idx = (int) ($_POST['chunk_index'] ?? 0);
|
||||
$total = (int) ($_POST['total_chunks'] ?? 1);
|
||||
$tmpFile = $stateDir . '/upload.sql';
|
||||
if (!$chunk || $chunk['error'] !== UPLOAD_ERR_OK) import_json(['ok' => false, 'error' => 'Chunk-Fehler']);
|
||||
$fh = fopen($tmpFile, $idx === 0 ? 'wb' : 'ab');
|
||||
fwrite($fh, file_get_contents($chunk['tmp_name']));
|
||||
if (!$chunk || $chunk['error'] !== UPLOAD_ERR_OK) import_json(['ok' => false, 'error' => import_upload_error($chunk)]);
|
||||
$fh = @fopen($tmpFile, $idx === 0 ? 'wb' : 'ab');
|
||||
if (!$fh) import_json(['ok' => false, 'error' => 'Die Datei konnte nicht gespeichert werden: Der Import-Ordner ist nicht beschreibbar (' . $stateDir . ').']);
|
||||
$written = fwrite($fh, (string) file_get_contents($chunk['tmp_name']));
|
||||
fclose($fh);
|
||||
if ($written === false || $written < (int) filesize($chunk['tmp_name'])) import_json(['ok' => false, 'error' => 'Die Datei konnte nicht vollständig gespeichert werden (Speicherplatz voll?).']);
|
||||
$done = ($idx + 1) >= $total;
|
||||
if ($done) {
|
||||
$_SESSION['import_sql_path'] = $tmpFile;
|
||||
@@ -64,8 +102,11 @@ if (($_POST['action'] ?? '') === 'use_path') {
|
||||
$filename = basename(trim($_POST['sql_filename'] ?? ''));
|
||||
$fullPath = realpath(__DIR__ . '/../' . $filename);
|
||||
$rootDir = realpath(__DIR__ . '/..');
|
||||
if (!$filename || !$fullPath || strpos($fullPath, $rootDir) !== 0 || !file_exists($fullPath)) {
|
||||
flash('error', 'Datei nicht gefunden: ' . $filename); redirect('/staff/import_osticket.php');
|
||||
if ($filename === '') {
|
||||
flash('error', 'Bitte den Dateinamen der SQL-Datei eintragen (das graue Beispiel im Feld ist nur ein Platzhalter).'); redirect('/staff/import_osticket.php');
|
||||
}
|
||||
if (!$fullPath || strpos($fullPath, $rootDir) !== 0 || !is_file($fullPath)) {
|
||||
flash('error', 'Datei nicht gefunden: ' . $filename . '. Sie muss im DeskHub-Hauptordner auf dem Server liegen (dort, wo auch index.php liegt).'); redirect('/staff/import_osticket.php');
|
||||
}
|
||||
$_SESSION['import_sql_path'] = $fullPath;
|
||||
$_SESSION['import_filename'] = $filename;
|
||||
@@ -170,7 +211,7 @@ require __DIR__ . '/_header.php';
|
||||
<button type="submit" class="btn">Verwenden →</button>
|
||||
</form>
|
||||
<hr style="border:none;border-top:1px solid var(--border);margin-bottom:20px">
|
||||
<p style="font-weight:600;font-size:.88rem;margin-bottom:8px">Option 2: Direkt hochladen (in 5-MB-Teilen)</p>
|
||||
<p style="font-weight:600;font-size:.88rem;margin-bottom:8px">Option 2: Direkt hochladen (in Teilen von <?= round(import_chunk_size() / 1048576, 1) ?> MB, passend zum Limit dieses Servers)</p>
|
||||
<input type="file" id="sql-file-input" accept=".sql" style="margin-bottom:10px">
|
||||
<div id="upload-progress" style="display:none;margin-bottom:10px">
|
||||
<div style="background:var(--border);border-radius:20px;height:10px;overflow:hidden;margin-bottom:5px">
|
||||
@@ -187,7 +228,7 @@ require __DIR__ . '/_header.php';
|
||||
var btn = document.getElementById('upload-btn');
|
||||
btn.disabled = true;
|
||||
document.getElementById('upload-progress').style.display = 'block';
|
||||
var chunkSize = 5*1024*1024, totalChunks = Math.ceil(file.size/chunkSize), idx = 0;
|
||||
var chunkSize = <?= import_chunk_size() ?>, totalChunks = Math.ceil(file.size/chunkSize), idx = 0;
|
||||
function next() {
|
||||
var blob = file.slice(idx*chunkSize, (idx+1)*chunkSize);
|
||||
var fd = new FormData();
|
||||
@@ -195,12 +236,13 @@ require __DIR__ . '/_header.php';
|
||||
fd.append('chunk',blob,file.name); fd.append('chunk_index',idx);
|
||||
fd.append('total_chunks',totalChunks); fd.append('filename',file.name);
|
||||
fetch('/staff/import_osticket.php',{method:'POST',body:fd})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
if(!d.ok){alert('Fehler: '+d.error);btn.disabled=false;return;}
|
||||
.then(r=>r.text()).then(function(t){
|
||||
var d; try { d = JSON.parse(t); } catch(e) { d = {ok:false,error:'Der Server hat den Upload-Teil nicht angenommen (vermutlich ist er größer als das Limit post_max_size ' + <?= json_encode((string) ini_get('post_max_size')) ?> + '). Laden Sie die Seite neu und versuchen Sie es erneut, oder legen Sie die Datei per FTP in den DeskHub-Ordner (Option 1).'}; }
|
||||
if(!d.ok){alert('Fehler: '+d.error);btn.disabled=false;document.getElementById('upload-bar').style.background='#dc2626';return;}
|
||||
idx++;
|
||||
var pct=Math.round(idx/totalChunks*100);
|
||||
document.getElementById('upload-bar').style.width=pct+'%';
|
||||
document.getElementById('upload-status').textContent=pct+'% - '+Math.round(idx*5)+' MB';
|
||||
document.getElementById('upload-status').textContent=pct+'% - '+Math.round(Math.min(idx*chunkSize,file.size)/1048576)+' von '+Math.round(file.size/1048576)+' MB';
|
||||
if(d.done){
|
||||
document.getElementById('upload-bar').style.background='#22c55e';
|
||||
setTimeout(()=>{window.location.href='/staff/import_osticket.php?step=analyze';},400);
|
||||
|
||||
@@ -35,16 +35,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$pass = $_POST['password'] ?? '';
|
||||
if (!rate_limit_check('staff_login_attempt', client_ip(), 10, 15)) {
|
||||
$errors[] = 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in einigen Minuten erneut.';
|
||||
} elseif ($email && !rate_limit_check('staff_login_email', $email, 5, 15)) {
|
||||
} elseif ($email && !rate_limit_check('staff_login_email', strtolower($email), 5, 15)) {
|
||||
// Konto-basierte Sperre – verhindert Brute-Force über wechselnde IPs
|
||||
$errors[] = 'Zu viele Anmeldeversuche für dieses Konto. Bitte warten Sie einige Minuten.';
|
||||
} else {
|
||||
$stmt = get_db()->prepare('SELECT * FROM staff WHERE email = ? AND is_active = 1');
|
||||
$stmt = get_db()->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
|
||||
$stmt->execute([$email]);
|
||||
$staff = $stmt->fetch();
|
||||
if ($staff && password_verify($pass, $staff['password_hash'])) {
|
||||
// Erfolgreicher Login – Zähler zurücksetzen
|
||||
rate_limit_reset('staff_login_email', $email);
|
||||
rate_limit_reset('staff_login_email', strtolower($email));
|
||||
if ($staff['totp_enabled']) {
|
||||
$_SESSION['totp_pending_staff_id'] = $staff['id'];
|
||||
$need2fa = true;
|
||||
@@ -79,7 +79,7 @@ $displayName = get_setting('display_name', config()['site']['name']);
|
||||
<?php endif; ?>
|
||||
<link rel="manifest" href="/manifest-staff.php">
|
||||
<meta name="theme-color" content="#0f6f6a">
|
||||
<?php if ($logo): ?><link rel="icon" href="<?= e($logo) ?>"><?php endif; ?>
|
||||
<?= site_icon_links() ?>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(function(){});}</script>
|
||||
</head>
|
||||
<body class="login-page staff-login-page" style="margin:0; display:flex; flex-direction:column; min-height:100vh; background:#16202a <?php if($banner): ?>; background-image:url('<?= e($banner) ?>'); background-size:cover; background-position:center<?php endif; ?>">
|
||||
@@ -90,7 +90,9 @@ $displayName = get_setting('display_name', config()['site']['name']);
|
||||
<?php if ($logo): ?>
|
||||
<img src="<?= e($logo) ?>" alt="" style="height:32px; width:auto">
|
||||
<?php endif; ?>
|
||||
<span style="color:#fff; font-weight:700; font-size:.95rem"><?= e($displayName) ?></span>
|
||||
<?php if (!$logo || get_setting('logo_hide_name', '0') !== '1'): ?>
|
||||
<span style="color:#fff; font-weight:700; font-size:.95rem"><?= e($displayName) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<a href="/login.php" style="color:rgba(255,255,255,.5); font-size:.8rem; text-decoration:none">← Zum Kundenportal</a>
|
||||
</header>
|
||||
@@ -125,9 +127,9 @@ $displayName = get_setting('display_name', config()['site']['name']);
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="login">
|
||||
<label for="email">E-Mail-Adresse</label>
|
||||
<input type="email" id="email" name="email" required autofocus autocomplete="email">
|
||||
<input type="email" id="email" name="email" required autofocus autocomplete="email" autocapitalize="none" autocorrect="off" spellcheck="false">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password" autocapitalize="none" autocorrect="off" spellcheck="false">
|
||||
<div style="text-align:right; margin-top:6px">
|
||||
<a href="/staff/forgot_password.php" style="font-size:.8rem">Passwort vergessen?</a>
|
||||
</div>
|
||||
|
||||
@@ -20,8 +20,9 @@ $menu = reset($registered);
|
||||
if ($menu['permission'] !== null && !has_permission($staff, $menu['permission'])) { http_response_code(403); exit('Keine Berechtigung.'); }
|
||||
|
||||
$pageTitle = $menu['label'];
|
||||
$activePage = 'plugin_' . $slug;
|
||||
$activePage = !empty($menu['belongs_to']) ? (string) $menu['belongs_to'] : 'plugin_' . $slug; // Reiter-Seiten markieren die übergeordnete Seite (zum Beispiel „Backup“)
|
||||
require __DIR__ . '/_header.php';
|
||||
if (!empty($menu['belongs_to'])) echo plugin_subpage_tabs((string) $menu['belongs_to'], $staff, $slug . ':' . $page);
|
||||
plugin_guard($slug, function () use ($file, $staff, $pdo) {
|
||||
// Variablen für die Plugin-Seite: $staff, $pdo
|
||||
include $file;
|
||||
|
||||
@@ -22,7 +22,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
// E-Mail-Duplikat prüfen (andere Mitarbeiter)
|
||||
if (!$errors) {
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE email = ? AND id != ?');
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE LOWER(email) = LOWER(?) AND id != ?');
|
||||
$dup->execute([$email, $staff['id']]);
|
||||
if ((int)$dup->fetchColumn()) $errors[] = 'Diese E-Mail-Adresse wird bereits von einem anderen Mitarbeiter verwendet.';
|
||||
}
|
||||
@@ -372,20 +372,11 @@ require __DIR__ . '/_header.php';
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_signature">
|
||||
<div class="richtext-wrap">
|
||||
<div class="richtext-toolbar" id="sig-toolbar">
|
||||
<button type="button" onclick="document.execCommand('bold')" title="Fett"><strong>B</strong></button>
|
||||
<button type="button" onclick="document.execCommand('italic')" title="Kursiv"><em>I</em></button>
|
||||
<button type="button" onclick="document.execCommand('underline')" title="Unterstrichen"><u>U</u></button>
|
||||
<button type="button" onclick="document.execCommand('insertUnorderedList')" title="Liste">•</button>
|
||||
<button type="button" onclick="insertLink()" title="Link">🔗</button>
|
||||
</div>
|
||||
<div class="richtext-editable" id="sig-editor" contenteditable="true" style="min-height:180px"><?= $me['signature'] ?? '' ?></div>
|
||||
</div>
|
||||
<input type="hidden" name="signature" id="sig-hidden">
|
||||
<textarea id="signature" name="signature" data-richtext data-image-upload-url="/staff/upload_signature_image.php" style="min-height:180px"><?= e($me['signature'] ?? '') ?></textarea>
|
||||
<p class="muted" style="font-size:.8rem;margin-top:8px">Bilder einfügen mit 🖼️. Zum Verkleinern oder Vergrößern das Bild anklicken: Mit dem runden Anfasser ziehen oder 25 / 50 / 75 % / Original wählen.</p>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn" onclick="document.getElementById('sig-hidden').value=document.getElementById('sig-editor').innerHTML">Speichern</button>
|
||||
<button type="button" class="btn secondary" onclick="document.getElementById('sig-editor').innerHTML='';document.getElementById('sig-hidden').value=''">Löschen</button>
|
||||
<button type="submit" class="btn">Speichern</button>
|
||||
<button type="button" class="btn secondary" id="sig-clear">Löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php if ($me['signature']): ?>
|
||||
@@ -399,12 +390,11 @@ require __DIR__ . '/_header.php';
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function insertLink() {
|
||||
var url = prompt('URL eingeben:', 'https://');
|
||||
if (url) document.execCommand('createLink', false, url);
|
||||
}
|
||||
document.querySelector('form').addEventListener('submit', function() {
|
||||
document.getElementById('sig-hidden').value = document.getElementById('sig-editor').innerHTML;
|
||||
document.getElementById('sig-clear').addEventListener('click', function () {
|
||||
var ta = document.getElementById('signature');
|
||||
var ed = ta.previousElementSibling;
|
||||
if (ed && ed.classList.contains('richtext-editable')) ed.innerHTML = '';
|
||||
ta.value = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ if (!$token || !$email) {
|
||||
$pdo->prepare('UPDATE staff SET password_hash = ? WHERE email = ?')
|
||||
->execute([password_hash($pass1, PASSWORD_DEFAULT), $email]);
|
||||
consume_password_reset('staff', $token);
|
||||
$staffRow = $pdo->prepare('SELECT id FROM staff WHERE email = ?');
|
||||
$staffRow = $pdo->prepare('SELECT id FROM staff WHERE LOWER(email) = LOWER(?)');
|
||||
$staffRow->execute([$email]);
|
||||
audit_log((int) $staffRow->fetchColumn(), 'Passwort per Reset-Link zurückgesetzt');
|
||||
$done = true;
|
||||
@@ -39,6 +39,7 @@ $pageTitle = 'Passwort zurücksetzen';
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Passwort zurücksetzen – Mitarbeiterbereich</title>
|
||||
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
|
||||
<?= site_icon_links() ?>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-wrap">
|
||||
|
||||
@@ -174,6 +174,33 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
else { set_setting('logo_path', '/assets/branding/logo.'.$ext.'?v='.time()); audit_log($staff['id'],'Logo aktualisiert'); flash('success','Logo aktualisiert.'); redirect('/staff/settings.php?tab=design'); }
|
||||
}
|
||||
}
|
||||
if ($action === 'upload_favicon') {
|
||||
$file = $_FILES['favicon'] ?? [];
|
||||
$ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION));
|
||||
$err = null;
|
||||
if (empty($file['name'])) $err = 'Bitte eine Datei auswählen.';
|
||||
elseif (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) $err = 'Upload fehlgeschlagen' . (($file['error'] ?? 0) === UPLOAD_ERR_INI_SIZE ? ' (die Datei ist größer als das Upload-Limit des Servers).' : '.');
|
||||
elseif ($file['size'] > 1024 * 1024) $err = 'Das Favicon darf höchstens 1 MB groß sein.';
|
||||
elseif (!isset(favicon_types()[$ext])) $err = 'Erlaubt: ICO, PNG, JPG, GIF, WebP, SVG.';
|
||||
else $err = favicon_validate_file($file['tmp_name'], $ext);
|
||||
if ($err !== null) { $errors[] = $err; }
|
||||
else {
|
||||
foreach (array_keys(favicon_types()) as $e2) @unlink($branding . '/favicon.' . $e2);
|
||||
if (!move_uploaded_file($file['tmp_name'], $branding . '/favicon.' . $ext)) { $errors[] = 'Speichern fehlgeschlagen (Ordner assets/branding/ nicht beschreibbar?).'; }
|
||||
else { set_setting('favicon_path', '/assets/branding/favicon.' . $ext . '?v=' . time()); audit_log($staff['id'], 'Favicon aktualisiert'); flash('success', 'Favicon aktualisiert. Browser zeigen Änderungen oft erst nach Neuladen (Strg+F5) oder in einem neuen Tab.'); redirect('/staff/settings.php?tab=design'); }
|
||||
}
|
||||
}
|
||||
if ($action === 'remove_favicon') {
|
||||
foreach (array_keys(favicon_types()) as $e2) @unlink($branding . '/favicon.' . $e2);
|
||||
set_setting('favicon_path', null); audit_log($staff['id'], 'Favicon entfernt'); flash('success', 'Favicon entfernt.'); redirect('/staff/settings.php?tab=design');
|
||||
}
|
||||
if ($action === 'save_logo_options') {
|
||||
$hide = !empty($_POST['logo_hide_name']) ? '1' : '0';
|
||||
set_setting('logo_hide_name', $hide);
|
||||
audit_log($staff['id'], $hide === '1' ? 'Name neben dem Logo ausgeblendet' : 'Name neben dem Logo wird angezeigt');
|
||||
flash('success', $hide === '1' ? 'Der Name wird neben dem Logo nicht mehr angezeigt.' : 'Der Name wird wieder neben dem Logo angezeigt.');
|
||||
redirect('/staff/settings.php?tab=design');
|
||||
}
|
||||
if ($action === 'remove_logo') {
|
||||
foreach (['png','jpg','jpeg','gif','svg'] as $e2) @unlink($branding.'/logo.'.$e2);
|
||||
set_setting('logo_path', null); audit_log($staff['id'],'Logo entfernt'); flash('success','Logo entfernt.'); redirect('/staff/settings.php?tab=design');
|
||||
@@ -556,7 +583,7 @@ require __DIR__ . '/_header.php';
|
||||
|
||||
<div class="card">
|
||||
<h3>Firmenlogo</h3>
|
||||
<p class="muted" style="font-size:.85rem;margin-bottom:16px">PNG/SVG empfohlen, max. 2 MB. Wird überall statt „DeskHub" angezeigt.</p>
|
||||
<p class="muted" style="font-size:.85rem;margin-bottom:16px">PNG/SVG empfohlen, max. 2 MB. Wird überall statt „DeskHub" angezeigt. Der Name der Installation steht standardmäßig daneben und lässt sich unten ausblenden.</p>
|
||||
<?php if ($currentLogo): ?>
|
||||
<div style="background:#16202a;padding:14px;border-radius:8px;margin-bottom:14px;display:inline-block">
|
||||
<img src="<?= e($currentLogo) ?>" alt="Logo" style="max-height:48px;max-width:220px">
|
||||
@@ -565,6 +592,14 @@ require __DIR__ . '/_header.php';
|
||||
<?= csrf_field() ?><input type="hidden" name="action" value="remove_logo">
|
||||
<button type="submit" class="btn secondary">Entfernen</button>
|
||||
</form>
|
||||
<form method="post" style="margin-top:14px">
|
||||
<?= csrf_field() ?><input type="hidden" name="action" value="save_logo_options">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:400;cursor:pointer">
|
||||
<input type="checkbox" name="logo_hide_name" value="1" style="width:auto" <?= get_setting('logo_hide_name', '0') === '1' ? 'checked' : '' ?>>
|
||||
Name „<?= e(get_setting('display_name', 'DeskHub')) ?>“ neben dem Logo ausblenden
|
||||
</label>
|
||||
<div class="btn-row"><button type="submit" class="btn secondary">Speichern</button></div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p class="muted">Kein Logo hinterlegt.</p>
|
||||
<?php endif; ?>
|
||||
@@ -575,6 +610,30 @@ require __DIR__ . '/_header.php';
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Favicon (Symbol im Browser-Tab)</h3>
|
||||
<p class="muted" style="font-size:.85rem;margin-bottom:16px">ICO, PNG, JPG, GIF, WebP oder SVG, höchstens 1 MB, am besten quadratisch (z. B. 256×256). Ohne Favicon verwendet DeskHub das Logo.</p>
|
||||
<?php $currentFavicon = get_setting('favicon_path'); ?>
|
||||
<?php if ($currentFavicon): ?>
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:14px">
|
||||
<span style="background:repeating-conic-gradient(#e5e7eb 0% 25%, #fff 0% 50%) 50% / 12px 12px;border:1px solid var(--border);border-radius:8px;padding:8px;display:inline-flex">
|
||||
<img src="<?= e($currentFavicon) ?>" alt="Favicon" style="width:32px;height:32px;object-fit:contain">
|
||||
</span>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Favicon entfernen?')">
|
||||
<?= csrf_field() ?><input type="hidden" name="action" value="remove_favicon">
|
||||
<button type="submit" class="btn secondary">Entfernen</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="muted">Kein eigenes Favicon hinterlegt.</p>
|
||||
<?php endif; ?>
|
||||
<form method="post" enctype="multipart/form-data" style="margin-top:16px">
|
||||
<?= csrf_field() ?><input type="hidden" name="action" value="upload_favicon">
|
||||
<input type="file" name="favicon" accept=".ico,.png,.jpg,.jpeg,.gif,.webp,.svg">
|
||||
<div class="btn-row"><button type="submit" class="btn">Hochladen</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Titelbild (Hero-Banner)</h3>
|
||||
<p class="muted" style="font-size:.85rem;margin-bottom:16px">Hintergrundbild im dunklen Hero-Bereich. Mind. 1600×500 px, max. 5 MB.</p>
|
||||
|
||||
@@ -20,7 +20,7 @@ $departments = $pdo->query('SELECT * FROM departments WHERE is_active = 1 ORDER
|
||||
$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
|
||||
$helpTopics = help_topic_options($pdo);
|
||||
$allAgents = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
|
||||
$customFields = $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 ORDER BY sort_order, label')->fetchAll();
|
||||
$customFields = $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 AND department_id IS NULL ORDER BY sort_order, label')->fetchAll();
|
||||
|
||||
// Abteilungen auf zugängliche einschränken
|
||||
if ($myDeptIds !== null) {
|
||||
@@ -285,6 +285,13 @@ require __DIR__ . '/_header.php';
|
||||
<option value="<?= e($opt) ?>" <?= $posted === $opt ? 'selected' : '' ?>><?= e($opt) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php elseif ($f['field_type'] === 'radio'): ?>
|
||||
<?php foreach (preg_split('/\r?\n/', trim((string)$f['options'])) as $opt): $opt = trim($opt); if (!$opt) continue; ?>
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:400; margin:4px 0">
|
||||
<input type="radio" name="<?= $fn ?>" value="<?= e($opt) ?>" style="width:auto" <?= $f['is_required'] ? 'required' : '' ?> <?= $posted === $opt ? 'checked' : '' ?>>
|
||||
<span><?= e($opt) ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
<?php elseif ($f['field_type'] === 'checkbox'): ?>
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:400; margin-top:4px">
|
||||
<input type="checkbox" id="<?= $fn ?>" name="<?= $fn ?>" value="1" style="width:auto" <?= !empty($posted) ? 'checked' : '' ?>>
|
||||
|
||||
@@ -61,7 +61,40 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['bulk_action'] ?? '') !== '
|
||||
}
|
||||
|
||||
$now = db_now();
|
||||
// Abteilung wechseln: Ziel prüfen (muss existieren und für den Mitarbeiter erreichbar sein), Rechte je Ticket später
|
||||
$moveTarget = null;
|
||||
$movedCount = 0;
|
||||
$movedSkipped = ['same' => 0, 'perm' => 0];
|
||||
if ($bulkAction === 'department') {
|
||||
$newDeptId = (int) ($_POST['bulk_department_id'] ?? 0);
|
||||
$targetStmt = $pdo->prepare('SELECT id, name FROM departments WHERE id = ?');
|
||||
$targetStmt->execute([$newDeptId]);
|
||||
$moveTarget = $targetStmt->fetch() ?: null;
|
||||
if (!$moveTarget) {
|
||||
$bulkErrors[] = 'Bitte eine Ziel-Abteilung wählen.';
|
||||
$allowedTickets = [];
|
||||
} elseif ($myDeptIds !== null && !in_array($newDeptId, $myDeptIds, true)) {
|
||||
$bulkErrors[] = 'Sie haben keinen Zugriff auf die Abteilung „' . $moveTarget['name'] . '“.';
|
||||
$allowedTickets = [];
|
||||
}
|
||||
}
|
||||
foreach ($allowedTickets as $t) {
|
||||
if ($bulkAction === 'department' && $moveTarget) {
|
||||
if ((int) $t['department_id'] === (int) $moveTarget['id']) { $movedSkipped['same']++; continue; }
|
||||
if (!has_permission($staff, 'transfer_ticket', (int) $t['department_id'])) { $movedSkipped['perm']++; continue; }
|
||||
// SLA-Fristen nach den Regeln der neuen Abteilung neu berechnen (wie beim Ändern in der Ticketansicht)
|
||||
$prioStmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
|
||||
$prioStmt->execute([$t['priority_id']]);
|
||||
$prioRow = $prioStmt->fetch();
|
||||
$sla = $prioRow ? calc_sla_dates($prioRow, $t['created_at'], (int) $moveTarget['id'], !empty($t['help_topic_id']) ? (int) $t['help_topic_id'] : null) : null;
|
||||
$pdo->prepare('UPDATE tickets SET department_id=?, first_response_due_at=?, resolution_due_at=?, updated_at=? WHERE id=?')
|
||||
->execute([(int) $moveTarget['id'], $sla['first_response_due_at'] ?? $t['first_response_due_at'], $sla['resolution_due_at'] ?? $t['resolution_due_at'], $now, $t['id']]);
|
||||
$oldDeptName = '';
|
||||
foreach ($departments as $d0) { if ((int) $d0['id'] === (int) $t['department_id']) $oldDeptName = $d0['name']; }
|
||||
log_activity($pdo, $t['id'], $staff['id'], 'Abteilung geändert (Sammelaktion): ' . $oldDeptName . ' → ' . $moveTarget['name']);
|
||||
$movedCount++;
|
||||
continue;
|
||||
}
|
||||
if ($bulkAction === 'assign') {
|
||||
$newAgent = $_POST['bulk_assigned_staff_id'] !== '' ? (int) $_POST['bulk_assigned_staff_id'] : null;
|
||||
$pdo->prepare('UPDATE tickets SET assigned_staff_id=?, updated_at=? WHERE id=?')->execute([$newAgent, $now, $t['id']]);
|
||||
@@ -96,7 +129,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['bulk_action'] ?? '') !== '
|
||||
}
|
||||
}
|
||||
|
||||
if (!$bulkErrors) {
|
||||
if (!$bulkErrors && $bulkAction === 'department') {
|
||||
$msg = $movedCount . ' Ticket(s) in die Abteilung „' . $moveTarget['name'] . '“ verschoben.';
|
||||
if ($movedSkipped['same']) $msg .= ' ' . $movedSkipped['same'] . ' waren schon dort.';
|
||||
if ($movedSkipped['perm']) $msg .= ' ' . $movedSkipped['perm'] . ' übersprungen (keine Berechtigung zum Verschieben).';
|
||||
flash($movedCount > 0 ? 'success' : 'error', $msg);
|
||||
} elseif (!$bulkErrors) {
|
||||
$verb = ['assign' => 'zugewiesen', 'priority' => 'Priorität geändert', 'status' => 'Status geändert', 'delete' => 'gelöscht'][$bulkAction] ?? 'aktualisiert';
|
||||
flash('success', count($allowedTickets) . ' Ticket(s) ' . $verb . '.');
|
||||
} else {
|
||||
@@ -542,6 +580,14 @@ document.getElementById('qf-panel')?.addEventListener('click', function(e){ e.st
|
||||
<button type="button" class="btn secondary" style="white-space:nowrap" onclick="submitBulk('priority')">Priorität setzen</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<select id="bulk_department_id" name="bulk_department_id">
|
||||
<option value="">– Abteilung wählen –</option>
|
||||
<?php foreach ($visibleDepartments as $d): ?><option value="<?= (int) $d['id'] ?>"><?= e($d['name']) ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
<button type="button" class="btn secondary" style="white-space:nowrap" onclick="if(!document.getElementById('bulk_department_id').value){alert('Bitte eine Ziel-Abteilung wählen.');return;} submitBulk('department')">Abteilung ändern</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<select id="bulk_status" name="bulk_status">
|
||||
<?php foreach (['open'=>'Offen','pending'=>'Wartet auf Kunde','resolved'=>'Gelöst','closed'=>'Geschlossen'] as $val=>$lbl): ?>
|
||||
|
||||
Reference in New Issue
Block a user