Compare commits

...

35 Commits

Author SHA1 Message Date
60eea92f11 feat(wiki): Beitragsliste für Betriebsrat-Nutzer auf zugängliche Kategorien filtern
BR-Nutzer (r-wiki-br ohne r-wiki) sehen in der Beitragsliste und im
Kategorie-Dropdown nur Beiträge und Kategorien, die ihren freigegebenen
Kategorien (betriebsrat_editable=1 + alle Nachfahren per BFS) zugeordnet sind.

Der Filter wird server-seitig in post_listform.php berechnet und als Parameter
an den Ajax-Endpunkt übergeben, da get_permission_state() im Ajax-Kontext
nicht verfügbar ist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:07:48 +02:00
5da7916651 fix(wiki): Speichern-Button für Betriebsrat-Wiki-Nutzer sichtbar machen
Copy-paste-Fehler: Berechtigung wurde doppelt gegen r-wiki geprüft statt
gegen r-wiki-br, wodurch BR-Nutzer den Speichern-Button nie sahen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:07:32 +02:00
8aadf65cc3 feat(einricht): Verknüpfte Einrichtungen bei Mehrfachauswahl automatisch mitselektieren
Wenn eine Einrichtung mit hinterlegten Links ausgewählt wird, werden alle
verknüpften Einrichtungen (aus organigramm_einricht_link) automatisch
mitausgewählt. Gilt für alle 5 Einrichtungs-Multiselects (3× Semantic UI
Dropdown, 2× Tagify).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:07:25 +02:00
ab4f38a360 feat(einricht): Einrichtungen miteinander verknüpfen
Neue Tabelle organigramm_einricht_link für n:m-Beziehungen zwischen
Einrichtungen. In der Bearbeitungskarte gibt es jetzt ein
Mehrfachauswahlfeld (Semantic UI Dropdown) um verwandte Einrichtungen
zu verknüpfen (z.B. alle HzE-Einrichtungen unter der Gruppe HzE).

- Neue Funktion saveLinkedEinrichts() speichert die Verknüpfungen
- Cardform lädt bestehende Links als Vorauswahl (Strings, kein int-Cast
  damit Semantic UI den data-value-Vergleich korrekt auflöst)
- onChange-Handler + explizite hidden-input-Befüllung sichern
  zuverlässige Übergabe beim Formular-Submit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 08:46:11 +02:00
61dde2925b chore: Filesgallery-Cache-Images aus Git ausschließen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 08:46:00 +02:00
3aa523870f fix(wiki): Betriebsrat-UI vollständig korrigiert
- categories_posts_listform.php: brCategoryFilter entfernt, Betriebsrat
  sieht alle Kategorien (nur "Beitrag erstellen"-Button gefiltert)
- post_cardform_article.php: Bearbeiten- und "Neue Version"-Button jetzt
  über \$canEditPost gesteuert statt hart auf r-wiki geprüft
- post_cardform.php: Settings-Tab-Link und -Inhalt ebenfalls über
  \$canEditPost freigegeben
- post_cardform_settings.php: Kategorie-Whitelist wird für Betriebsrat
  auf freigegebene Kategorien gefiltert (inkl. Vererbung);
  Speichern-Button und JS-Handler über \$canEditPost gesteuert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 08:09:19 +02:00
4d6cc807c9 feat(wiki): Kategorienvererbung für betriebsrat_editable, Ajax-Absicherung
- is_category_betriebsrat_editable(): läuft den Kategoriebaum nach oben durch
  (via knowledgecenter_category_links), sodass Unterkategorien einer
  freigegebenen Kategorie automatisch ebenfalls als editierbar gelten
- ajax_is_category_editable_for_br(): gleiche Logik für den Ajax-Kontext
- ajax_can_edit_post(): nutzt jetzt Vererbung statt direkter Kategorienprüfung
- create_post_draft(): ajax_has_wiki_edit()-Check hinzugefügt
- update_post_version(): Permission-Check mit Sonderfall neue Drafts (noch
  keine Kategorien → Prüfung auf has_wiki_edit begrenzt)
- update_post_categories(): Betriebsrat darf nur freigegebene Kategorien
  (inkl. Vererbung) zuweisen
- upload_inline_image.php: Bild-Upload prüft ebenfalls den Kategoriebaum

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 08:08:59 +02:00
a0698d621e feat(wiki): Betriebsrat-Berechtigung (r-wiki-br)
Neue Permission r-wiki-br ermöglicht eingeschränkte Wiki-Bearbeitung:

- Neue Permission in main_permission (ID 30: r-wiki-br)
- Neue Spalte betriebsrat_editable in knowledgecenter_categories_update
- Checkbox "Für Betriebsrat bearbeitbar" in der Kategorie-Verwaltung
  (nur für r-wiki-Redakteure sichtbar und speicherbar)

Berechtigungslogik:
- r-wiki schlägt immer: Wer beide Rechte hat, verhält sich wie Redakteur
- Betriebsrat sieht nur Kategorien mit betriebsrat_editable=1
- "Beitrag erstellen"-Button nur in freigegebenen Kategorien sichtbar
- Neue Posts/Bearbeitungen auf betriebsrat_editable-Kategorien beschränkt
- Settings-Tab und Kategorieverwaltung weiterhin r-wiki-only
- Ajax-Endpoints (save_post_files, delete_post_file, upload_inline_image)
  prüfen Kategorie-Berechtigung serverseitig

File Gallery:
- Betriebsrat ist per kc_lock_path auf den Post-eigenen Ordner beschränkt
- Redakteur kann frei navigieren (kein Lock)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:16:36 +02:00
07f9f304a4 fix(wiki-migration): pdftotext als primären PDF-Extraktor nutzen
pdfparser scheitert an "secured" PDFs (Kopier-Schutz ohne Passwort).
pdftotext aus poppler-utils umgeht diesen Schutz und extrahiert den
eingebetteten Text korrekt. Falls pdftotext nicht verfügbar ist oder
nichts liefert, bleibt pdfparser als Fallback erhalten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 13:40:47 +02:00
9817fde8c4 fix(wiki-migration): PDF-Extraktion über alle filegallery-Sitepart-Typen
get_article_files() filterte nur sitepart_id=5, obwohl PDFs auch über
sitepart_id=6 und 10 verknüpft sind. Direkter JOIN auf filegallery_line
mit IN (5,6,10) findet jetzt alle Anhänge korrekt.

Außerdem: Token-sparsamere generate_summary-Logik (Teaser priorisiert,
Text auf 1500 Z. gecappt, nur 1 PDF á 800 Z., maxOutputTokens=150),
--summaries-only-Flag für Nachläufe, sleep(4) für Free-Tier-Rate-Limit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 13:33:56 +02:00
0bf47247ff chore: Weiterleitungs-Icons, Mobile-Nav-Fix, Aufräumen
- Forwarding: neue Icons Glocke und Einstellungen, SVG-Syntax korrigiert
- global.css: Hint-Badge in mobiler Navigation korrekt positioniert
- text_constants.inc.php entfernt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 10:00:33 +02:00
54d9194d22 refactor(quill): Toolbar-Presets als Factory-Funktionen, Resize-Plugin überarbeitet
Toolbar-Presets liefern jetzt frische Arrays (Factory-Pattern), damit
mehrere Editor-Instanzen auf einer Seite sich nicht gegenseitig
beeinflussen. Resize-Modul und CSS angepasst.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 10:00:25 +02:00
8ff44a407e feat(wiki): Kategoriebeschreibungen, Kacheldesign und Migration
- Migrationsskript: Teaser/Content getrennt (kc_teaser id=55 → teaser,
  kc_article id=58 → content), Gemini-Summaries mit thinkingBudget=0,
  Retry-Logik für 503/429-Fehler
- Berechtigungsfehler behoben: AND active=1 aus main_contact_department-
  Abfrage entfernt (Middleware + Ajax)
- Kategoriebeschreibungen: neues description-Feld in
  knowledgecenter_category_translations, aus kc.tags befüllt
- Kacheln: Hauptebene erhält post_wiki-Klasse für einheitliches Design,
  Beschreibungstext in Grid-Ansicht, in Listenansicht ausgeblendet
- Buttons: Beitrag-erstellen nutzt --clr-mandant, alle vier Toolbar-
  Buttons auf gleiche Höhe normiert (line-height: 1 + SVG 1em)
- Platzhalterpfade für Kategorien und Beiträge korrigiert
- Zusammenfassung in Beitragsansicht angezeigt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 10:00:17 +02:00
ee966de174 Fix: Aufgaben-Badge in Navigation wurde außerhalb des <a>-Tags ausgegeben
echo \$my_tasks_dot stand vor dem <li>, sodass der rote Badge als
freies Element zwischen den Navigationspunkten erschien. Badge-Span
wird jetzt innerhalb des <a>-Tags gerendert (analog zu \$collection_dot),
und \$my_tasks_dot wird zu Beginn jeder Loop-Iteration auf "" gesetzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 14:08:38 +02:00
55e6d4c583 Fix: Tasks-Endpoints ignorierten die Monolith-Session (sidawo)
bootstrap.php rief session_start() mit Standard-Name PHPSESSID auf,
weil start.inc.php für Endpoint-Requests bewusst nicht geladen wird.
Das führte zu einer leeren Session → contactId = 0 → "Keine Berechtigung".

Lösung: Vor session_start() prüfen ob ein sid*-Cookie vorhanden ist
und ggf. session_name() entsprechend setzen, damit Endpoints und
Monolith dieselbe Session nutzen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 13:56:17 +02:00
cf97a52128 Fix: Aufgaben-Formular (neu anlegen) zeigte User-Detail statt Admin-Formular
Sitepart 68 ("Bearbeitungsmaske") hatte den falschen Typ 36
(user/task_detail.php). Dadurch wurde beim Öffnen der "neue Aufgabe"-
Seite der User-Task-Detail-Action aufgerufen, die ohne assignment_id
mit "Ungültige Assignment-ID." abbricht.

Lösung: Typ 40 in intranet.php für admin/task_form.php eingetragen,
Sitepart 68 in der DB auf Typ 40 gesetzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 13:52:05 +02:00
833c3718cf Add summary field to knowledgecenter posts
- ALTER TABLE knowledgecenter_post_versions ADD COLUMN summary TEXT NULL AFTER content
- Add summary textarea to post edit form (after Quill editor)
- Include summary in insert_post_version() and update_post_version()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 11:59:31 +02:00
952b3e2efc Remove topic table query from post_cardform.php
The forum module (topic table) does not exist. Remove the comment count
query to prevent a fatal crash when loading a post detail page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 11:52:42 +02:00
887ac6a4bb Fix post save and file gallery for knowledgecenter
- Create module/shared_components/quill/ with toolbar_presets.js,
  quill_resize.js and quill_resize.css — missing files caused a JS crash
  in $(document).ready() before the save button's click handler was
  registered, so clicking save did nothing
- Fix filesgallery.php session detection to search any sid* cookie
  dynamically (site session name is 'sidawo', not the hardcoded
  'sidintranet'), fixing the popup showing the homepage instead of
  the gallery
- Revert categories_cardform.php from 3-tab layout back to original
  sidebar layout with full-width permissions section below

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 11:49:59 +02:00
0a0196bd89 knowledgecenter: remove forum/product tabs and fix new-post errors
- post_cardform.php: remove Diskussion tab (forum module doesn't exist)
- post_cardform_contacts.php: guard get_contact_cards AJAX so it doesn't
  fire with post_id=0 on new posts (caused "Ungültige Beitrags-ID" error)
- post_cardform_settings.php: remove Produkte section entirely — the
  product/product_translation tables don't exist in this installation,
  which caused a fatal crash when opening the settings tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 11:30:05 +02:00
d1b9e89df3 knowledgecenter: tab layout for category form + fix post discussion crash
categories_cardform.php now uses a 3-tab layout (Allgemein / Berechtigungen /
Unterkategorien) instead of a content+sidebar layout. The permissions section
spans full width with a 6-column grid: Mandanten/Abteilungen/Rollen each in
2 cols, Einrichtungen/Fachbereiche each in 3 cols for more space.

post_cardform.php: the Diskussion tab crashed with a fatal require_once when
the forum module doesn't exist. Now guarded with file_exists() and shows a
placeholder message for new posts and missing modules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 11:25:15 +02:00
4e8dd8745f knowledgecenter: add Einrichtung/Fachbereich to category permission form
categories_cardform.php now loads and displays tagify pickers for
Einrichtungen and Fachbereiche alongside the existing Mandant/Abteilung/Rolle
pickers. The saveTagifyData() function passes all 5 types to update_category_links.

Also ran DB migration: all permission data from the 5 old _link tables has been
copied into the new permission tables (mandant: 178, department: 1108,
role: 2159, einricht: 10023, fachbereich: 2722 rows).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 11:13:27 +02:00
eb3aa75615 Add Einrichtung and Fachbereich as permission/filter types (knowledgecenter + tasks)
- Create knowledgecenter_category_einricht, _fachbereich, knowledgecenter_post_einricht,
  knowledgecenter_post_fachbereich tables; extend task_target_rule with main_einricht_id
  and main_bereich_id columns
- Middleware: load main_einricht_id/main_bereich_id from main_contact_department,
  expose $allowedEinrichts/$allowedFachbereiche globals (same pattern as existing 3 types)
- Ajax: add update_category/post_einrichts/fachbereiche functions, extend
  update_category_links() and search filter query
- Views: add einricht/fachbereich LEFT JOINs and WHERE filters in all 5 query sites
  (categories_posts_listform, category_posts_widget, post_announcement_listform,
  post_cardform userHasAccessForPost, Ajax search)
- post_cardform_settings: add Einrichtungen/Fachbereiche tagify pickers with save/load
- Tasks: add listEinrichts/listFachbereiche to repo+service, add UI selects in task_form,
  extend target-rule normalization and upsert builder, extend AssignmentResolverService
  scope loading and matchesAnyRule to include einricht/fachbereich matching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 11:03:53 +02:00
8fa061afac Merge 'weitere konstanten.php' into master and group/sort all constants
Added 293 new keys from 'weitere konstanten.php' (1429 → 1722 total).
Master file is now grouped into 45 thematic sections (Allgemein, Login,
Navigation, Intranet, Tasks, Events, Wiki, etc.), each alphabetically
sorted. 'weitere konstanten.php' is no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 10:30:13 +02:00
1bd08a72be Fix text_constants merge: use proper PHP string regex
The previous merge used a greedy (.*?); regex that stopped at the first
semicolon — breaking on HTML entities like &ouml; inside string values.
Switched to a quoted-string-aware regex ("..." / '...') that correctly
captures full PHP string literals including embedded semicolons.
Key count: 1429 (root file was untracked and never loaded, not a loss).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 10:24:52 +02:00
e3b4dbe4ff Merge all text_constants into a single canonical file
Four separate text_constants.inc.php files (root, mysyde/common,
mysyde/common/classes, mysyde/admin) contained overlapping and
diverging constants. Merged all 1596 unique keys—zero value conflicts—
into mysyde/admin/text_constants.inc.php, which Translate.php already
loads. The three other files are now thin stubs that require_once the
admin file, so all existing include paths continue to work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 10:22:28 +02:00
e2837b3288 Adapt main_contact_link queries to use main_contact_department
The new tasks and knowledgecenter_update modules were built against a
main_contact_link table that does not exist in this system. All 7 active
query sites now target main_contact_department (same column names,
active_d → active).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 10:22:20 +02:00
38272c3b11 Intranet Cardform: zwei Nummernserien auf ein Array konsolidieren
Statt $type_array (1-basiert) + $type_name_array (0-basiert) gibt es
jetzt ein einziges assoziatives Array $types [typ_nummer => name].
input_select bekommt array_keys/array_values — keine Verhaltensänderung,
aber das Muster ist ab sofort konsistent und erweiterbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 09:44:50 +02:00
197152fb97 Intranet: neue Module registrieren (Typen 28–39)
Knowledgecenter Update, Globale Suche und alle Task/Zertifikate-Seiten
(Admin + User) ab Typ 28 fortlaufend eingetragen — kein Offset mehr
zwischen Label und Case-Nummer wie im Quellprojekt.

Nicht eingetragen: my_certificates.php (Datei existiert nicht im
tasks-Modul) und Search (Modul noch nicht vorhanden).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 09:40:54 +02:00
743476f64a Module tasks + knowledgecenter_update aus Kundenprojekt übernehmen (bereinigt)
Beide Module wurden aus einem anderen Kundenprojekt kopiert und bereinigt:

Knowledgecenter:
- Bild-Cache (1.985 Dateien) geleert, Ordnerstruktur mit .gitkeep behalten
- Files-Gallery-Lizenzschlüssel entfernt (config.php)
- Kundenspezifische Kategorien gelöscht: Teil I/II/III QM-Struktur,
  nummerierte QM-Kapitel, Gremien (Präsidium/Finanzausschuss/Landesausschuss),
  Sitzungsservice-Serie, Protokoll-Abteilungen, Testeinträge
- 56 generische Kategorien bleiben (Downloads, Onboarding, Personalthemen …)

Tasks:
- Alle Betriebsdaten gelöscht (114 Tasks, 914 Submissions, 579 Assignments)
- Task-Definitionen und Verlinkungen geleert
- Kategoriestruktur bleibt (Allgemein, IT, Personal, Buchhaltung …)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 09:20:02 +02:00
2e75ff7d59 Statistik: Login-Tracking auf pro-User-Deduplizierung umstellen
main_contact_id wird jetzt mitgespeichert + UNIQUE (login_date, main_contact_id).
Damit zählt ein User der sich mehrfach ein-/ausloggt trotzdem nur einmal pro Tag.
Im Chart bleibt nur die anonyme Tagessumme sichtbar.

ALTER TABLE stat_login_daily
  ADD COLUMN main_contact_id INT NOT NULL DEFAULT 0 AFTER login_date,
  ADD UNIQUE KEY uq_user_day (login_date, main_contact_id);

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 08:59:44 +02:00
5c585caeba Dashboard: Anmelde-Statistik als Balkendiagramm hinzufügen
Zeigt anonyme Anmeldungen der letzten 30 Tage als Bar-Chart
(Chart.js, bereits im Admin geladen). Nur Tagessummen sichtbar,
keine Rückschlüsse auf einzelne Nutzer möglich.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 08:52:16 +02:00
b2a3a23116 Statistik: anonyme Login-Events tracken
Neue Tabelle stat_login_daily (nur Datum, keine User-ID).
Bei jedem erfolgreichen Login wird ein Eintrag geschrieben —
egal ob über Microsoft OAuth, Dev-Bypass oder klassisches Passwort-Login.

CREATE TABLE stat_login_daily (
  id INT AUTO_INCREMENT PRIMARY KEY,
  login_date DATE NOT NULL,
  INDEX idx_login_date (login_date)
);

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 08:52:09 +02:00
0ece15a23b Logout: Microsoft-Redirect im Dev-Modus umgehen
Beim Abmelden wurde bisher immer auf login.microsoftonline.com weitergeleitet,
was lokal ins Leere läuft. Bei DISABLE_MS_AUTH=1 wird stattdessen
/?action=intranet_logout verwendet (Desktop- und Mobile-Button).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:04:13 +02:00
c55777c089 Login: Microsoft-Auth per Env-Variable deaktivierbar machen
Im lokalen Docker-Betrieb funktioniert der Microsoft-OAuth-Flow nicht.
DISABLE_MS_AUTH=1 in docker-compose.yml schaltet ihn ab und zeigt
stattdessen ein einfaches Dev-Login-Formular (Eingabe der User-ID
aus main_contact). Auf 0 setzen, um MS-Auth wieder zu aktivieren.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:03:53 +02:00
158 changed files with 45826 additions and 8998 deletions

6
.gitignore vendored
View File

@@ -23,4 +23,8 @@ docker/
*.csv
*.png
.DS_Store
.DS_Store
# Filesgallery-Cache (generierte Thumbnails)
module/knowledgecenter_update/Plugins/_files/cache/images/*
!module/knowledgecenter_update/Plugins/_files/cache/images/.gitkeep

View File

@@ -558,6 +558,10 @@ span.hint {
flex-shrink: 0;
}
nav span.hint {
left: -0;
}
.profil_banner.certificate {
width: 33.33%;
float: left;
@@ -9187,6 +9191,13 @@ a:not([class]) {
text-decoration-skip-ink: auto;
}
.mobile_navigation_container .hint {
position: absolute;
right: 15px;
top: -5px;
left: unset;
}
.mobile_navigation svg {
width: 19px;
height: 19px;

View File

@@ -2893,18 +2893,50 @@ function multi_select_permission($collection_id, $type){
<script type="text/javascript" src="/plugins/jquery/jquery-3.5.1.min.js"></script>
<script type="text/javascript" src="/plugins/Semantic-UI-master/semantic.min.js"></script>
<link rel="stylesheet" type="text/css" href="/plugins/Semantic-UI-master/bc-semantic.css?time=<?= filemtime($_SERVER['DOCUMENT_ROOT'] . '/plugins/Semantic-UI-master/bc-semantic.css')?>">
<?php
$einrichtLinks = [];
if ($type == "einricht") {
$linkRes = mysqli_query($GLOBALS['mysql_con'],
"SELECT einricht_id, linked_einricht_id FROM organigramm_einricht_link");
while ($lr = mysqli_fetch_assoc($linkRes)) {
$einrichtLinks[strval($lr['einricht_id'])][] = strval($lr['linked_einricht_id']);
}
}
?>
<script>
var $y = jQuery.noConflict();
$y( document ).ready(function() {
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('set selected', <?= json_encode($preselect) ?>);
var $drop = $y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>');
<?php if ($type == "einricht"): ?>
var einrichtLinks = <?= json_encode($einrichtLinks) ?>;
var busy = false;
$drop.dropdown({
onChange: function(value) {
if (busy) return;
var current = value ? value.split(',').filter(Boolean) : [];
var toAdd = [];
current.forEach(function(id) {
(einrichtLinks[id] || []).forEach(function(lid) {
if (current.indexOf(lid) < 0 && toAdd.indexOf(lid) < 0) toAdd.push(lid);
});
});
if (toAdd.length) {
busy = true;
$drop.dropdown('set selected', current.concat(toAdd));
busy = false;
}
}
});
<?php endif; ?>
$drop.dropdown('set selected', <?= json_encode($preselect) ?>);
});
$("<?php echo "#all_get".$type;?>").click(function() {
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('set selected', <?= json_encode($allselect) ?>);
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('set selected', <?= json_encode($allselect) ?>);
});
$("<?php echo "#all_del".$type;?>").click(function() {
console.log("delete");
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('clear');
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('clear');
});
</script>
<?

View File

@@ -94,20 +94,24 @@ $forwarding_icon_images = array(
<path id="Pfad_280" data-name="Pfad 280" d="M18,11a7,7,0,1,1-7,7,7,7,0,0,1,7-7"/>
<path id="Pfad_281" data-name="Pfad 281" d="M18,34A16,16,0,1,1,34,18,16,16,0,0,1,18,34ZM18,4A14,14,0,1,0,32,18,14,14,0,0,0,18,4Z"/>
</g>
</svg>
'
</svg>'
),
"communityboard" => array(
"name" => "Communityboard",
"image" => '<svg xmlns="https://www.w3.org/2000/svg" width="18.98" height="14.914" viewBox="0 0 18.98 14.914">
<path id="comments-o" d="M7.457,1.356a8.244,8.244,0,0,0-3.03.551A5.677,5.677,0,0,0,2.187,3.4a3.01,3.01,0,0,0-.831,2.023A2.886,2.886,0,0,0,1.917,7.1,4.875,4.875,0,0,0,3.5,8.495l1.028.593-.371.89q.36-.212.657-.413l.466-.328.562.106a9.171,9.171,0,0,0,1.621.148,8.244,8.244,0,0,0,3.03-.551,5.677,5.677,0,0,0,2.24-1.494,3.01,3.01,0,0,0,.831-2.023A3.01,3.01,0,0,0,12.727,3.4a5.676,5.676,0,0,0-2.241-1.494,8.249,8.249,0,0,0-3.03-.551ZM7.457,0A9.543,9.543,0,0,1,11.2.725,6.719,6.719,0,0,1,13.918,2.7a4.22,4.22,0,0,1,0,5.444A6.728,6.728,0,0,1,11.2,10.121a9.533,9.533,0,0,1-3.744.725,10.654,10.654,0,0,1-1.864-.169,8.9,8.9,0,0,1-2.945,1.356,8.823,8.823,0,0,1-.911.169H1.705a.331.331,0,0,1-.217-.085.35.35,0,0,1-.122-.222.217.217,0,0,1-.011-.069.421.421,0,0,1,.005-.069.23.23,0,0,1,.021-.064l.026-.053.037-.058.042-.053.047-.053.042-.047q.053-.064.244-.265a4.008,4.008,0,0,0,.275-.313q.084-.112.238-.307a2.62,2.62,0,0,0,.265-.408q.111-.212.217-.466A6.2,6.2,0,0,1,.752,7.8,4.155,4.155,0,0,1,0,5.423,4.279,4.279,0,0,1,1,2.7,6.723,6.723,0,0,1,3.712.725,9.543,9.543,0,0,1,7.457,0Zm8.706,12.382q.106.254.217.466a2.672,2.672,0,0,0,.265.408q.153.2.238.307t.275.313q.19.2.244.265l.042.047q.032.037.047.053a.449.449,0,0,1,.042.053.475.475,0,0,1,.037.058l.026.053.021.064.005.069-.011.069a.392.392,0,0,1-.138.233.319.319,0,0,1-.233.074,9.054,9.054,0,0,1-.911-.169,8.916,8.916,0,0,1-2.945-1.356,10.654,10.654,0,0,1-1.864.169,8.9,8.9,0,0,1-5-1.4q.614.042.932.042a11.192,11.192,0,0,0,3.273-.477,9.368,9.368,0,0,0,2.8-1.367,7.086,7.086,0,0,0,2.034-2.246,5.451,5.451,0,0,0,.466-4.3A6.268,6.268,0,0,1,18.185,5.7a4.134,4.134,0,0,1,.043,4.814A6.221,6.221,0,0,1,16.163,12.382Z"/>
</svg>
'
</svg>'
),
"glocke" => array(
"name" => "Glocke",
"image" => '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-bell" viewBox="0 0 16 16">
<path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2M8 1.918l-.797.161A4 4 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4 4 0 0 0-3.203-3.92zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5 5 0 0 1 13 6c0 .88.32 4.2 1.22 6"/>
</svg>'
),
"einstellungen" => array(
"name" => "Einstellungen",
"image" => '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-sliders" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M11.5 2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3M9.05 3a2.5 2.5 0 0 1 4.9 0H16v1h-2.05a2.5 2.5 0 0 1-4.9 0H0V3zM4.5 7a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3M2.05 8a2.5 2.5 0 0 1 4.9 0H16v1H6.95a2.5 2.5 0 0 1-4.9 0H0V8zm9.45 4a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3m-2.45 1a2.5 2.5 0 0 1 4.9 0H16v1h-2.05a2.5 2.5 0 0 1-4.9 0H0v-1z"/>
</svg>'
)
);

View File

@@ -1,4 +1,12 @@
<?
function track_anonymous_login($contact_id) {
$id = (int)$contact_id;
if ($id <= 0) return;
@mysqli_query($GLOBALS['mysql_con'],
"INSERT IGNORE INTO stat_login_daily (login_date, main_contact_id) VALUES (CURDATE(), $id)"
);
}
support_login_listener();
@@ -41,6 +49,7 @@ function support_login_listener() {
$_SESSION["login_error"] = "Dieser Account wurde noch nicht freigeschalten.";
}else{
$_SESSION['login_id'] = $main_contact['id'];
track_anonymous_login($main_contact['id']);
header('location:/');
}
}else{

View File

@@ -10,6 +10,7 @@ require_once("intranet_functions.inc.php");
if (isset($_POST["fgztiknxbhhk"])) {
$_SESSION['login_id'] = $_POST["fgztiknxbhhk"];
track_anonymous_login($_POST["fgztiknxbhhk"]);
header('location:/');
exit();
}
@@ -20,7 +21,7 @@ $translation = \DynCom\mysyde\common\classes\Registry::get('translation');
$isAzure = true;
$isAzure = getenv('DISABLE_MS_AUTH') !== '1';
if ($isAzure) {
@@ -73,6 +74,7 @@ if ($isAzure) {
// $sql_opt = "SELECT id FROM main_contact WHERE email='".$email_l."'";
$_SESSION['login_id'] = @mysqli_fetch_assoc($sql_opt)['id'];
track_anonymous_login($_SESSION['login_id']);
header('location:/');
}else {
$_SESSION['login_id'] = 0;
@@ -260,11 +262,33 @@ getQuote();
}
}
else {
?>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dev Login</title>
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f0f0f0; }
.box { background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.15); min-width: 280px; }
.box h2 { margin: 0 0 1rem; font-size: 1.1rem; color: #c00; }
.box label { display: block; margin-bottom: .25rem; font-size: .85rem; color: #555; }
.box input[type=number] { width: 100%; box-sizing: border-box; padding: .5rem; border: 1px solid #ccc; border-radius: 4px; margin-bottom: 1rem; font-size: 1rem; }
.box button { width: 100%; padding: .6rem; background: #0078d4; color: #fff; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; }
.box button:hover { background: #005fa3; }
.note { margin-top: .75rem; font-size: .75rem; color: #999; text-align: center; }
</style>
</head>
<body>
<div class="box">
<h2>⚠️ Dev-Login (kein Microsoft)</h2>
<form method="post">
<label>User-ID (aus main_contact)</label>
<input type="number" name="fgztiknxbhhk" min="1" required autofocus />
<button type="submit">Einloggen</button>
</form>
<p class="note">Nur lokal verfügbar — DISABLE_MS_AUTH=1 in docker-compose.yml</p>
</div>
</body>
<?php
}
?>

View File

@@ -41,41 +41,50 @@ if ($messages !== null) {
}
}
//Arrays für Select füllen
$type_array = array(
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27
);
$type_name_array = array(
0 => "Kontaktdaten",
1 => "Schwarzes Brett",
2 => "Kontakt bearbeiten",
3 => "Essenslieferung",
4 => "Profil",
5 => "Infoboard",
6 => "Knowledgecenter",
7 => "Firmeninformationen",
8 => "Meine Kontakte",
9 => "Knowledgecenter - Weitere Beiträge",
10 => "E-Learning",
11 => "Zertifikate - Kontaktkarte",
12 => "Essenslieferung Übersicht",
13 => "Zukünftige Termine",
14 => "E-Learning - Kontaktkarte",
15 => "Playground",
16 => "Tasks",
17 => "Tasks Vorschau",
18 => "Knowledgecenter - Custom Feed",
19 => "Arbeitsplatz Übersicht",
20 => "Personenkarten",
21 => "Organigramm",
22 => "PDF Suchen",
23 => "PDF Vorschau",
24 => "QM Updates",
25 => "QR-Code",
26 => "Ticketcenter"
);
//Arrays für Select füllen — Typ-Nummer direkt als Key
$types = [
1 => "Kontaktdaten",
2 => "Schwarzes Brett",
3 => "Kontakt bearbeiten",
4 => "Essenslieferung",
5 => "Profil",
6 => "Infoboard",
7 => "Knowledgecenter",
8 => "Firmeninformationen",
9 => "Meine Kontakte",
10 => "Knowledgecenter - Weitere Beiträge",
11 => "E-Learning",
12 => "Zertifikate - Kontaktkarte",
13 => "Essenslieferung Übersicht",
14 => "Zukünftige Termine",
15 => "E-Learning - Kontaktkarte",
16 => "Playground",
17 => "Tasks",
18 => "Tasks Vorschau",
19 => "Knowledgecenter - Custom Feed",
20 => "Arbeitsplatz Übersicht",
21 => "Personenkarten",
22 => "Organigramm",
23 => "PDF Suchen",
24 => "PDF Vorschau",
25 => "QM Updates",
26 => "QR-Code",
27 => "Ticketcenter",
28 => "Knowledgecenter Update",
29 => "Globale Suche",
30 => "Aufgaben Admin Aufgabenliste",
31 => "Aufgaben Admin Vorschau",
32 => "Aufgaben Admin Eskalations Dashboard",
33 => "Aufgaben Admin Freigabe Dashboard",
34 => "Aufgaben Admin Kategorien",
35 => "Aufgaben User Aufgaben",
36 => "Aufgaben User Aufgabe Bearbeitung",
37 => "Aufgaben User Überfällig",
38 => "Aufgaben User Hinweisleiste",
39 => "Aufgaben User Eskalations Overlay",
];
$input_description = (empty($input_intranet['description']) ? $type_name_array[$input_intranet['type']] : $input_intranet['description']);
$input_description = (empty($input_intranet['description']) ? ($types[$input_intranet['type']] ?? '') : $input_intranet['description']);
?>
@@ -91,7 +100,7 @@ $input_description = (empty($input_intranet['description']) ? $type_name_array[$
<td>
<div class='label'><label for="input_description"><?= $translation->get('description') ?></label></div>
<div class='input'><input type="text" name="input_description" id="input_description" value="<?= $input_description ?>" class="text"></div>
<? input_select($translation->get('content_type'),'input_type',$type_array, $type_name_array, $input_intranet['type']); ?>
<? input_select($translation->get('content_type'), 'input_type', array_keys($types), array_values($types), $input_intranet['type']); ?>
<?php
if ($input_intranet['id'] != "" && $input_intranet['type'] == 12 || $input_intranet['type'] == 15) { // E-Learning - Kontaktkarte
echo input('Mit Personenkarte anzeigen', "input_learning_certificate_type", "checkbox", $input_intranet["learning_certificate_type"], 255);

View File

@@ -88,6 +88,45 @@ function intranet_show($sitepart_id) {
case 27:
$include = MODULE_PATH.'/ticketcenter/views/ticketcenter/show_ticketcenter.inc.php';
break;
case 28:
$include = MODULE_PATH.'/knowledgecenter_update/Controllers/knowledgecenter.inc.php';
break;
case 29:
$include = MODULE_PATH.'/search/views/search_results.php';
break;
case 30:
$include = MODULE_PATH.'/tasks/pages/admin/task_list.php';
break;
case 31:
$include = MODULE_PATH.'/tasks/pages/admin/task_detail.php';
break;
case 32:
$include = MODULE_PATH.'/tasks/pages/admin/escalation_dashboard.php';
break;
case 33:
$include = MODULE_PATH.'/tasks/pages/admin/approval_queue.php';
break;
case 34:
$include = MODULE_PATH.'/tasks/pages/admin/task_categories.php';
break;
case 35:
$include = MODULE_PATH.'/tasks/pages/user/my_tasks.php';
break;
case 36:
$include = MODULE_PATH.'/tasks/pages/user/task_detail.php';
break;
case 37:
$include = MODULE_PATH.'/tasks/pages/user/overdue_widget.php';
break;
case 38:
$include = MODULE_PATH.'/tasks/pages/user/summary_widget.php';
break;
case 39:
$include = MODULE_PATH.'/tasks/pages/user/escalation_overlay_widget.php';
break;
case 40:
$include = MODULE_PATH.'/tasks/pages/admin/task_form.php';
break;
default:
break;
}

View File

@@ -572,18 +572,50 @@ function multi_select_permission($collection_id, $type){
<script type="text/javascript" src="/plugins/jquery/jquery-3.5.1.min.js"></script>
<script type="text/javascript" src="/plugins/Semantic-UI-master/semantic.min.js"></script>
<link rel="stylesheet" type="text/css" href="/plugins/Semantic-UI-master/bc-semantic.css?time=<?= filemtime($_SERVER['DOCUMENT_ROOT'] . '/plugins/Semantic-UI-master/bc-semantic.css')?>">
<?php
$einrichtLinks = [];
if ($type == "einricht") {
$linkRes = mysqli_query($GLOBALS['mysql_con'],
"SELECT einricht_id, linked_einricht_id FROM organigramm_einricht_link");
while ($lr = mysqli_fetch_assoc($linkRes)) {
$einrichtLinks[strval($lr['einricht_id'])][] = strval($lr['linked_einricht_id']);
}
}
?>
<script>
var $y = jQuery.noConflict();
$y( document ).ready(function() {
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('set selected', <?= json_encode($preselect) ?>);
var $drop = $y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>');
<?php if ($type == "einricht"): ?>
var einrichtLinks = <?= json_encode($einrichtLinks) ?>;
var busy = false;
$drop.dropdown({
onChange: function(value) {
if (busy) return;
var current = value ? value.split(',').filter(Boolean) : [];
var toAdd = [];
current.forEach(function(id) {
(einrichtLinks[id] || []).forEach(function(lid) {
if (current.indexOf(lid) < 0 && toAdd.indexOf(lid) < 0) toAdd.push(lid);
});
});
if (toAdd.length) {
busy = true;
$drop.dropdown('set selected', current.concat(toAdd));
busy = false;
}
}
});
<?php endif; ?>
$drop.dropdown('set selected', <?= json_encode($preselect) ?>);
});
$("<?php echo "#all_get".$type;?>").click(function() {
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('set selected', <?= json_encode($allselect) ?>);
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('set selected', <?= json_encode($allselect) ?>);
});
$("<?php echo "#all_del".$type;?>").click(function() {
console.log("delete");
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('clear');
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('clear');
});
</script>
<?

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,187 @@
<?php
/**
* Inline-Image-Upload für Quill im Wiki-Editor.
* Wird vom imageHandler in post_cardform.php aufgerufen.
*
* Erwartet (multipart/form-data POST):
* - file : Hochgeladenes Bild (jpg/png/gif/webp, <= 5 MB)
* - post_id : int > 0 (knowledgecenter_posts.id)
*
* Antwort (JSON):
* { "success": true, "url": "/userdata/knowledgecenter_update/posts/<hash>/inline/<sha>.<ext>" }
* { "success": false, "error": "..." } (HTTP 4xx/5xx)
*/
session_start();
$currDir = __DIR__;
$rootDir = dirname(dirname(dirname(dirname($currDir))));
$mysydeDir = $rootDir . '/mysyde';
require_once($rootDir . '/vendor/autoload.php');
require_once($mysydeDir . '/frontend/frontend_functions.inc.php');
$envDir = $rootDir . '/config';
$envFilePath = $envDir . '/.env';
if (is_dir($envDir) && file_exists($envFilePath) && is_file($envFilePath) && is_readable($envFilePath)) {
$dotenv = new \Dotenv\Dotenv($envDir);
$dotenv->load();
}
require_once($mysydeDir . '/common/common_functions.inc.php');
$connEnv = (local_environment()) ? $mysydeDir . '/dc.config.php' : $mysydeDir . '/dc-server.config.php';
require_once($connEnv);
db_connect();
require_once($rootDir . '/module/intranet/intranet_functions.inc.php');
header('Content-Type: application/json');
function respond_error($msg, $code = 400)
{
http_response_code($code);
echo json_encode(['success' => false, 'error' => $msg]);
exit;
}
if (empty($_SESSION['login_id'])) {
respond_error('Nicht angemeldet.', 401);
}
$loginId = (int) $_SESSION['login_id'];
$q = mysqli_query($GLOBALS['mysql_con'], "SELECT id, master_mandant_id FROM main_contact WHERE id = $loginId LIMIT 1");
if (!$q || !($contactRow = mysqli_fetch_assoc($q))) {
respond_error('Benutzer nicht gefunden.', 401);
}
$contactId = (int) $contactRow['id'];
$masterMandantId = (int) $contactRow['master_mandant_id'];
$hasRwiki = get_permission_state('r-wiki', $masterMandantId, $contactId) == 1;
$hasRwikiBr = get_permission_state('r-wiki-br', $masterMandantId, $contactId) == 1;
if (!$hasRwiki && !$hasRwikiBr) {
respond_error('Keine Berechtigung.', 403);
}
$postId = isset($_POST['post_id']) ? (int) $_POST['post_id'] : 0;
if ($postId <= 0) {
respond_error('Ungültige post_id.');
}
$verifyQ = mysqli_query($GLOBALS['mysql_con'], "SELECT id FROM knowledgecenter_posts WHERE id = $postId LIMIT 1");
if (!$verifyQ || mysqli_num_rows($verifyQ) === 0) {
respond_error('Beitrag existiert nicht.', 404);
}
// Betriebsrat darf Bilder nur für Posts in freigegebenen Kategorien hochladen (inkl. Vererbung)
if (!$hasRwiki && $hasRwikiBr) {
$brAllowed = false;
$brRes = mysqli_query($GLOBALS['mysql_con'],
"SELECT category_id FROM knowledgecenter_category_post WHERE post_id = $postId");
while ($brRow = mysqli_fetch_assoc($brRes)) {
$catId = (int)$brRow['category_id'];
// Baum nach oben absuchen
$visited = [];
$queue = [$catId];
while (!empty($queue)) {
$current = array_shift($queue);
if (isset($visited[$current])) continue;
$visited[$current] = true;
$chkRes = mysqli_query($GLOBALS['mysql_con'],
"SELECT betriebsrat_editable FROM knowledgecenter_categories_update WHERE id = $current LIMIT 1");
$chkRow = mysqli_fetch_assoc($chkRes);
if ($chkRow && (int)$chkRow['betriebsrat_editable'] === 1) {
$brAllowed = true;
break 2;
}
$pRes = mysqli_query($GLOBALS['mysql_con'],
"SELECT parent_category_id FROM knowledgecenter_category_links WHERE child_category_id = $current");
while ($pRow = mysqli_fetch_assoc($pRes)) {
$pid = (int)$pRow['parent_category_id'];
if (!isset($visited[$pid])) $queue[] = $pid;
}
}
}
if (!$brAllowed) {
respond_error('Keine Berechtigung für diesen Beitrag.', 403);
}
}
if (!isset($_FILES['file']) || !is_array($_FILES['file'])) {
respond_error('Keine Datei empfangen.');
}
$file = $_FILES['file'];
if ($file['error'] !== UPLOAD_ERR_OK) {
respond_error('Upload-Fehler (Code ' . (int) $file['error'] . ').');
}
$maxSize = 5 * 1024 * 1024;
if (!is_uploaded_file($file['tmp_name'])) {
respond_error('Ungültiger Upload.');
}
if ($file['size'] <= 0 || $file['size'] > $maxSize) {
respond_error('Datei zu groß oder leer (max 5 MB).');
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = $finfo ? finfo_file($finfo, $file['tmp_name']) : false;
if ($finfo) {
finfo_close($finfo);
}
$mimeToExt = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
if (!$mime || !isset($mimeToExt[$mime])) {
respond_error('Ungültiger Dateityp. Erlaubt: JPG, PNG, GIF, WEBP.');
}
$ext = $mimeToExt[$mime];
$hash = substr(sha1('kc_post_' . $postId), 0, 24);
$relBase = 'knowledgecenter_update/posts/' . $hash;
$absBase = $rootDir . '/userdata/' . $relBase;
$absInline = $absBase . '/inline';
if (!is_dir($absBase) && !@mkdir($absBase, 0775, true)) {
respond_error('Post-Ordner konnte nicht erstellt werden.', 500);
}
$sharedLink = $absBase . '/shared';
if (!is_link($sharedLink) && !file_exists($sharedLink)) {
@symlink('../../shared', $sharedLink);
}
if (!is_dir($absInline) && !@mkdir($absInline, 0775, true)) {
respond_error('Inline-Ordner konnte nicht erstellt werden.', 500);
}
$sha = hash_file('sha256', $file['tmp_name']);
if (!$sha) {
respond_error('Hashing fehlgeschlagen.', 500);
}
$finalName = $sha . '.' . $ext;
$finalPath = $absInline . '/' . $finalName;
$publicUrl = '/userdata/' . $relBase . '/inline/' . $finalName;
if (file_exists($finalPath)) {
if (filesize($finalPath) === (int) $file['size']) {
echo json_encode(['success' => true, 'url' => $publicUrl, 'dedup' => true]);
exit;
}
}
$tmpDest = $absInline . '/.tmp_' . bin2hex(random_bytes(6));
if (!move_uploaded_file($file['tmp_name'], $tmpDest)) {
respond_error('Datei konnte nicht gespeichert werden.', 500);
}
@chmod($tmpDest, 0664);
if (!rename($tmpDest, $finalPath)) {
@unlink($tmpDest);
respond_error('Datei konnte nicht verschoben werden.', 500);
}
echo json_encode(['success' => true, 'url' => $publicUrl]);
exit;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,976 @@
<?php
// ini_set('display_errors', 1);
// ini_set('display_startup_errors', 1);
// error_reporting(E_ALL);
$translation = \DynCom\mysyde\common\classes\Registry::get("translation");
$languageCode = isset($_GET['language']) ? $_GET['language'] : 'de';
$languageCode = mysqli_real_escape_string($GLOBALS['mysql_con'], $languageCode);
$languageId = $GLOBALS['language']['id'] ?? 1;
$isEmbedded = (isset($_GET['embedded']) && $_GET['embedded'] == 1) ? 1 : 0;
// Dependencies einbinden
require_once(__DIR__ . '/../Dependencies/Dependencies.php');
require_once(__DIR__ . '/../Middleware/Middleware.php');
if ($_COOKIE['recaptcha'] == 1) { ?>
<script src='https://www.google.com/recaptcha/api.js'></script>
<?
}
$c_form = "";
foreach ($_GET as $key => $value) {
if (preg_match('/^c_form\d+$/', $key)) {
// Do something with $key and $value
$c_form = $key;
}
}
$translation = \DynCom\mysyde\common\classes\Registry::get("translation");
$spid = 0;
$userId = 0;
$addUser = FALSE;
if (isset($_GET[$c_form]) && !empty($_POST)) {
$sitepart_id = $_GET[$c_form];
$queryFormData = "SELECT * FROM contactform_header WHERE id = '" . $sitepart_id . "'";
$resultFormData = @mysqli_query($GLOBALS['mysql_con'], $queryFormData);
$contactFormData = @mysqli_fetch_array($resultFormData);
$header_id = $contactFormData['id'];
// var_dump($header_id);
// echo "test";
$requestmassage = "";
$success = false;
$_REQUEST['input_contactform'] = $header_id;
// daten sammeln
$query = "SELECT * FROM contactform_header WHERE id = '" . $sitepart_id . "'";
$result = @mysqli_query($GLOBALS['mysql_con'], $query);
$contactform_header = @mysqli_fetch_array($result);
$filetypeError = FALSE;
$error = FALSE;
$captchError = FALSE;
$message = "";
/** Neu Abfrage aus welcher Seite Anfrage geschickt wurde */
$navigation_pfad = $_SERVER['HTTP_HOST'];
$navigation_pfad .= navigation_path($GLOBALS["site"], $GLOBALS["language"], $GLOBALS["navigation"]);
// $navigation_pfad .= $_SERVER['REQUEST_URI'];
$ajaxHelper = "";
if (isset($_GET['ajax'])) {
$ajaxHelper = "~|~";
}
//recaptcha
if ($contactform_header['recaptcha_active'] == 1) {
$secretKey = $GLOBALS['site']['secretkey'];
$responseKey = $_POST['g-recaptcha-response'];
$userIP = $_SERVER['REMOTE_ADDR'];
$url = "https://www.google.com/recaptcha/api/siteverify?secret=$secretKey&response=$responseKey&remoteip=$userIP";
$response = file_get_contents($url);
$data = json_decode($response);
if ($data->success == FALSE) {
$error = TRUE;
$captchError = TRUE;
}
}
$query = "SELECT * FROM contactform_line WHERE header_id = '" . $sitepart_id . "' ORDER BY sorting ASC";
$result = @mysqli_query($GLOBALS['mysql_con'], $query);
if (@mysqli_num_rows($result) > 0) {
// Initialize helpers
unset($field_errors);
$field_errors = [];
$attachements = []; // raw filenames in /userdata/mail_attachments/
$file = []; // final paths for mail_create
$submission_data = [];
$filetypeError = FALSE;
$error = FALSE;
// contact form linked in collection (keep your existing logic)
if (
isset($_POST['contact_field_id'], $_POST['main_collection_id']) &&
$_POST['contact_field_id'] != 0 && $_POST['main_collection_id'] != 0
) {
$queryLinked = "SELECT * FROM main_collection_contact_linked WHERE main_collection_id = " . (int) $_POST['main_collection_id'] . " AND field_id = " . (int) $_POST['contact_field_id'];
$resultLinked = @mysqli_query($GLOBALS['mysql_con'], $queryLinked);
if (@mysqli_num_rows($resultLinked) > 0) {
$row = @mysqli_fetch_array($resultLinked);
$contactform_header["recipient_email"] = $row['receiver_email'];
$contactform_header["recipient_name"] = $row['receiver_name'];
}
$queryCollection = "SELECT mc.*, mcs.collection_type FROM main_collection as mc INNER JOIN main_collection_setup as mcs on mc.main_collection_setup_id = mcs.id WHERE mc.id = '" . (int) $_POST['main_collection_id'] . "' ";
$resultCollection = @mysqli_query($GLOBALS['mysql_con'], $queryCollection);
if (@mysqli_num_rows($resultCollection) > 0) {
$collection = @mysqli_fetch_array($resultCollection);
if ((int) $collection['collection_type'] === 2) {
$queryLinked = "SELECT * FROM bewerbung WHERE main_collection_id = " . (int) $_POST['main_collection_id'] . " AND contact_id = " . (int) $header_id . " AND user_id = " . (int) $userId;
$resultLinked = @mysqli_query($GLOBALS['mysql_con'], $queryLinked);
if (@mysqli_num_rows($resultLinked) > 0) {
$addUser = FALSE;
$error = TRUE;
$requestmassage .= "<div class=\"danger infobox\">" . $translation->get('user_exist') . "<br></div>";
} else {
$queryInsert = "INSERT INTO bewerbung (contact_id,user_id,main_collection_id, modified_date)
VALUES ('" . (int) $header_id . "', '" . (int) $userId . "', '" . (int) $_POST['main_collection_id'] . "', '" . time() . "')";
$addUser = TRUE;
@mysqli_query($GLOBALS['mysql_con'], $queryInsert);
}
}
}
}
// Iterate all fields and build message + attachments + submission_data
while ($field = @mysqli_fetch_array($result)) {
$field_id = (int) $field['id'];
$field_code = $field['code']; // may be null in legacy data
$input_key = "input_" . ($field_code ?? $field_id);
$input_value = $_POST[$input_key] ?? '';
$linked_value = '';
// Basic mandatory check (skip special cases & file types)
if (
($field["code"] !== 'registration_name' && $field["code"] !== 'registration_email') &&
empty($_POST[$input_key]) &&
!empty($field["mandatory"]) &&
// skip file(8), radio(10) special handling in their cases
($field["typ"] != 8 && $field["typ"] != 10)
) {
$error = TRUE;
$field_errors[] = $field_id;
}
// --- FULL SWITCH-CASE ---
switch ($field["typ"]) {
case 1: // Single-line text
if ($field['code'] === 'subject' && isset($_POST[$input_key])) {
$contactform_header["subject"] = trim((string) $_POST[$input_key]);
}
$val = $_POST[$input_key] ?? '';
$message .= "<strong>" . $field["name"] . ":</strong> " . $val . "\n";
$input_value = $val;
$linked_value = '';
break;
case 2: // Multiline textarea
$raw = $_POST[$input_key] ?? '';
// Normalize newlines for mail rendering
$search = array('\r\n', '\n\r', '\r', '\n');
$replace = array(PHP_EOL, PHP_EOL, PHP_EOL, PHP_EOL);
$clean = str_replace($search, $replace, $raw);
$message .= "<strong>" . $field["name"] . ":</strong> " . $clean . "\n";
$input_value = $clean;
$linked_value = '';
break;
case 3: // Boolean (checkbox)
$checked = !empty($_POST[$input_key]);
$boolText = $checked ? "Ja" : "Nein";
$message .= "<strong>" . $field["name"] . ":</strong> " . $boolText . "\n";
$input_value = $checked ? '1' : '0';
$linked_value = '';
break;
case 4: // Select / special codes (mandant, user)
if ($field['code'] === 'mandant') {
// Hidden/system value — not printed, but stored
$input_value = $_POST[$input_key] ?? '';
$linked_value = '';
break;
} elseif ($field['code'] === 'user') {
$username = $GLOBALS['main_contact']['name'] ?? '';
$message .= "<strong>" . $field["name"] . ":</strong> " . $username . "\n";
$input_value = $username;
$linked_value = '';
break;
} else {
$val = $_POST[$input_key] ?? '';
$message .= "<strong>" . $field["name"] . ":</strong> " . $val . "\n";
$input_value = $val;
$linked_value = '';
}
// Linked fields for multistep/survey (optional free-text per option)
if (!empty($contactFormData['is_multistep']) || !empty($contactFormData['is_survey'])) {
$qOpt = 'SELECT * FROM contactform_line_option WHERE line_id = ' . $field_id;
$resOpt = @mysqli_query($GLOBALS['mysql_con'], $qOpt);
if ($resOpt) {
while ($opt = @mysqli_fetch_array($resOpt)) {
if (!empty($opt['linked_field'])) {
$lk = "inputlinked_" . $field_id . '_' . (int) $opt['id'];
if (isset($_POST[$lk]) && $_POST[$lk] !== '') {
$message .= $_POST[$lk] . "\n";
$linked_value = $_POST[$lk]; // store last linked input
}
}
}
}
}
break;
case 5: // Static label/header
$message .= "<strong>" . $field["name"] . "</strong>\n";
$input_value = ''; // purely presentational
$linked_value = '';
break;
case 6: // Separator / line break
$message .= "\n";
$input_value = '';
$linked_value = '';
break;
case 7: // Title taken from input
$val = $_POST[$input_key] ?? '';
$message .= "<strong>" . $val . "</strong>\n";
$input_value = $val;
$linked_value = '';
break;
case 8: // File upload (single or multiple)
// Ensure arrays exist
if (!isset($attachements) || !is_array($attachements)) {
$attachements = [];
}
// HTML input name expected as "input_<code>" or "input_<code>[]"
$uploadField = $input_key;
// Normalize $_FILES to array of file hashes
$files = [];
if (isset($_FILES[$uploadField])) {
if (is_array($_FILES[$uploadField]['name'])) {
$cnt = count($_FILES[$uploadField]['name']);
for ($i = 0; $i < $cnt; $i++) {
$files[] = [
'name' => $_FILES[$uploadField]['name'][$i],
'type' => $_FILES[$uploadField]['type'][$i],
'tmp_name' => $_FILES[$uploadField]['tmp_name'][$i],
'error' => $_FILES[$uploadField]['error'][$i],
'size' => $_FILES[$uploadField]['size'][$i],
];
}
} else {
$files[] = $_FILES[$uploadField];
}
}
// Mandatory check for files
if ((empty($files) || ($files[0]['name'] ?? '') === '') && !empty($field["mandatory"])) {
$error = TRUE;
$input_value = '';
$linked_value = '';
break;
}
if (empty($files) || ($files[0]['name'] ?? '') === '') {
// No files and not mandatory
$input_value = '';
$linked_value = '';
break;
}
// Target directory
$uploadDir = rtrim(dirname(dirname(dirname(__DIR__))), "/") . '/userdata/mail_attachments/';
if (!is_dir($uploadDir)) {
@mkdir($uploadDir, 0775, true);
}
// Extension policies
$allowed = ['pdf', 'png', 'jpg', 'jpeg', 'gif', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'zip', 'rar', '7z', 'txt', 'csv', 'mp4', 'mov', 'avi', 'mkv'];
$blocked = ['php', 'phtml', 'phar', 'cgi', 'pl', 'exe', 'sh', 'bat', 'cmd', 'com', 'js', 'msi'];
$uploadedNames = [];
foreach ($files as $f) {
// Skip unsuccessful items
if (!isset($f['error']) || $f['error'] !== UPLOAD_ERR_OK || ($f['name'] ?? '') === '') {
continue;
}
$ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));
if (in_array($ext, $blocked, true) || ($ext !== '' && !in_array($ext, $allowed, true))) {
$filetypeError = TRUE;
$error = TRUE;
continue;
}
// Optional: size limit (e.g., 25MB)
// if (($f['size'] ?? 0) > 25 * 1024 * 1024) { $filetypeError = TRUE; $error = TRUE; continue; }
// Sanitize filename & ensure uniqueness
$base = preg_replace('/[^A-Za-z0-9._-]+/', '_', $f['name']);
$dest = $uploadDir . $base;
$iUniq = 1;
$extSuffix = $ext ? ('.' . $ext) : '';
while (file_exists($dest)) {
$stem = pathinfo($base, PATHINFO_FILENAME);
$dest = $uploadDir . $stem . "_" . $iUniq . $extSuffix;
$iUniq++;
}
if (@move_uploaded_file($f['tmp_name'], $dest)) {
$attachements[] = basename($dest);
$uploadedNames[] = basename($dest);
} else {
$filetypeError = TRUE;
$error = TRUE;
}
}
// Mark field value & show list in message
$input_value = $uploadedNames ? 'FILE_ATTACHMENT' : '';
$linked_value = '';
if (!empty($uploadedNames)) {
$message .= "<strong>" . $field["name"] . ":</strong> " . implode(', ', $uploadedNames) . "\n";
}
break;
case 9: // Checkbox group (multi-select) — uses field id in POST
$vals = $_POST["input_" . $field_id] ?? null;
if (is_array($vals) && !empty($vals)) {
$lines = implode('<br>', array_map('trim', $vals));
$message .= "<strong>" . $field["name"] . ":</strong><br>" . $lines . "\n";
$input_value = implode(', ', $vals);
} else {
$input_value = '';
}
$linked_value = '';
// Linked inputs per chosen option (multistep)
if (!empty($contactFormData['is_multistep'])) {
$qOpt = 'SELECT * FROM contactform_line_option WHERE line_id = ' . $field_id;
$resOpt = @mysqli_query($GLOBALS['mysql_con'], $qOpt);
if ($resOpt) {
while ($opt = @mysqli_fetch_array($resOpt)) {
if (!empty($opt['linked_field'])) {
$lk = "inputlinked_" . $field_id . '_' . (int) $opt['id'];
if (isset($_POST[$lk]) && $_POST[$lk] !== '') {
$message .= $_POST[$lk] . "\n";
$linked_value = $_POST[$lk];
}
}
}
}
}
break;
case 10: // Single choice (radio/select) — uses field id in POST
$chosen = $_POST["input_" . $field_id] ?? '';
if ($chosen !== '') {
$message .= "<strong>" . $field["name"] . ":</strong> " . $chosen . "\n";
}
$input_value = $chosen;
$linked_value = '';
// Linked input for selected option (multistep/survey)
if (!empty($contactFormData['is_multistep']) || !empty($contactFormData['is_survey'])) {
$qOpt = 'SELECT * FROM contactform_line_option WHERE line_id = ' . $field_id;
$resOpt = @mysqli_query($GLOBALS['mysql_con'], $qOpt);
if ($resOpt) {
while ($opt = @mysqli_fetch_array($resOpt)) {
if ($opt['option_string'] === $chosen && !empty($opt['linked_field'])) {
$lk = "inputlinked_" . $field_id . '_' . (int) $opt['id'];
if (isset($_POST[$lk]) && $_POST[$lk] !== '') {
$message .= $_POST[$lk] . "\n";
$linked_value = $_POST[$lk];
}
break;
}
}
}
}
break;
case 11: // Date
$raw = $_POST[$input_key] ?? '';
$formatted = '';
if ($raw !== '') {
try {
$dt = new DateTime($raw);
$formatted = $dt->format('d.m.Y');
} catch (Throwable $e) {
$formatted = $raw; // fallback
}
}
$message .= "<strong>" . $field["name"] . ":</strong> " . $formatted . "\n";
$input_value = $raw; // store raw in DB
$linked_value = '';
break;
default:
// Unknown type — store raw if present
$val = $_POST[$input_key] ?? '';
if ($val !== '') {
$message .= "<strong>" . $field["name"] . ":</strong> " . $val . "\n";
}
$input_value = $val;
$linked_value = '';
break;
}
// Persist a normalized snapshot of the user's input for each field
$submission_data[] = [
'field_id' => $field_id,
'value' => is_array($input_value) ? implode(', ', $input_value) : (string) $input_value,
'linked_value' => is_array($linked_value) ? implode(', ', $linked_value) : (string) $linked_value,
];
}
}
if (($_POST["input_captcha1"] <> "") | ((time() - $_POST["input_captcha2"]) < 5)) {
$error = TRUE;
$requestmassage .= $GLOBALS['tc']['contactform_spam'];
}
if ($captchError == TRUE) {
echo $ajaxHelper . "<div class=\"danger infobox\">Bitte verwenden Sie das Captcha <br></div>" . $ajaxHelper;
} else {
echo $ajaxHelper . "" . $ajaxHelper;
}
if ($filetypeError === true) {
echo $ajaxHelper . "<div class=\"danger infobox\">Upload von PHP Dateien nicht erlaubt</div>" . $ajaxHelper;
}
//DUP breadcrumb upload mehrere Dateien update
if (!$error) {
//fügt Daten ins Array
foreach ($attachements as $attachement_line) {
$file[] = "/userdata/mail_attachments/" . $attachement_line;
}
// AUTOMATISCHE ANTWORT
if ($contactform_header["auto_answer"] != "" && $GLOBALS['main_contact']['email'] != "") {
if (
mail_create(
$contactform_header["subject"],
$contactform_header["auto_answer"],
$contactform_header["sender_email"],
$GLOBALS['main_contact']['email'],
$contactform_header["sender_name"],
$GLOBALS['main_contact']['email'],
FALSE,
"",
"",
"",
0,
"",
"",
$contact_form_id
)
) {
mail_send();
clearstatcache();
$success = true;
$requestmassage .= $GLOBALS['tc']['contactform_success'];
foreach ($_POST as $key => $value) {
unset($_POST[$key]);
}
} else {
$error = TRUE;
$requestmassage .= $translation->get('not_send1');
}
}
if ($sitepart_id == 1) {
$mandant_id = $GLOBALS["main_contact"]['master_mandant_id'];
$contact_id = $GLOBALS["main_contact"]['id'];
// $query = "SELECT * FROM main_contact_link LEFT JOIN main_collection as mc on mc.id = plg.portal_group_id WHERE plg.portal_login_id ='".$login_id."' AND plg.portal_group_id = '".$group_id."' AND plg.email != ''";
// $result = @mysqli_query($GLOBALS['mysql_con'], $query);
// $res = mysqli_fetch_array($result);
$contactform_header2["sender_email"] = $contactform_header["sender_email"];
$contactform_header2["sender_name"] = $contactform_header["sender_name"];
$contactform_header["sender_email"] = $GLOBALS["main_contact"]['email'];
$contactform_header["sender_name"] = $GLOBALS["main_contact"]['name'];
$contactform_header["subject"] = "Abwesenheit | {$GLOBALS["main_contact"]['name']}";
}
if ($sitepart_id == 2) {
$contactform_header["sender_email"] = $GLOBALS["main_contact"]['email'];
$contactform_header["sender_name"] = $GLOBALS["main_contact"]['name'];
$query = 'SELECT * from main_contact WHERE id = ' . $_REQUEST['input_user'] . ' LIMIT 1';
$result = @mysqli_query($GLOBALS['mysql_con'], $query);
if (@mysqli_num_rows($result) == 1) {
$user = @mysqli_fetch_array($result);
if ($user['email'] != NULL && $user['email'] != '') {
$contactform_header["recipient_name"] = $user['name'];
$contactform_header["recipient_email"] = $user['email'];
}
}
create_notification($_REQUEST['input_user'], $message);
}
// Sende E-Mail an Firmen-Email
if ($sitepart_id == 1) {
$query = "SELECT * FROM main_contact_department WHERE main_contact_id = '" . $GLOBALS["main_contact"]['id'] . "' AND sorting = 1";
$result = @mysqli_query($GLOBALS['mysql_con'], $query);
$input_arbeitsunfaehigkeitsbescheinigung = $_POST['input_arbeitsunfaehigkeitsbescheinigung'];
$input_date2 = new DateTime($_POST['input_date2']);
$input_date = new DateTime($_POST['input_date']);
$input_besprechung = $_POST['input_besprechung'];
$input_ansprechpartner = $_POST['input_ansprechpartner'];
while ($res = mysqli_fetch_array($result)) {
$query = "SELECT * FROM main_mandant WHERE id = " . $res['main_mandant_id'];
$result = @mysqli_query($GLOBALS['mysql_con'], $query);
$res_mandant = @mysqli_fetch_array($result);
$contactform_header2['recipient_email'] = $res_mandant['mail_team'];
$contactform_header2['recipient_name'] = $res_mandant['description'];
$message2 = "Liebes Team, \n\n";
if (isset($_POST['serial_code']) && $_POST['serial_code'] != "") {
$randomString = $_POST['serial_code'];
} else {
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$charactersLength = strlen($characters);
$randomString = '';
for ($i = 0; $i < 20; $i++) {
$randomString .= $characters[rand(0, $charactersLength - 1)];
}
}
if (isset($input_arbeitsunfaehigkeitsbescheinigung) && $input_date2 != "") {
$message2 .= $GLOBALS["main_contact"]['name'] . " ist bis einschließlich den " . $input_date2->format('d.m.Y') . " abwesend.\n";
$check_query = "SELECT events_type.id, events_type.sick_note FROM events_type WHERE events_type.sick_note = 1";
$check_result = mysqli_query($GLOBALS['mysql_con'], $check_query);
$check_row = mysqli_fetch_assoc($check_result);
$event_type_id = $check_row["id"];
$created_date = date(format: "Y-m-d H:i:s");
$modifiedDate = date(format: "Y-m-d H:i:s");
$date = clone $input_date;
while ($date <= $input_date2) {
$delete_query = "DELETE FROM events WHERE date_from = '" . $date->format('Y-m-d') . "' AND main_contact_id = '" . $GLOBALS['main_contact']['id'] . "'";
@mysqli_query($GLOBALS['mysql_con'], $delete_query);
$insert_query = "INSERT INTO events (
date_from,
date_to,
main_contact_id,
event_type_id,
serial_code,
created_date,
modified_date,
all_day,
active
) VALUES (
'" . $date->format('Y-m-d') . "',
'" . $date->format('Y-m-d') . "',
'" . $GLOBALS['main_contact']['id'] . "',
'" . $event_type_id . "',
'" . $randomString . "',
'" . $created_date . "',
'" . $modifiedDate . "',
1,
1
)";
@mysqli_query($GLOBALS['mysql_con'], $insert_query);
$date->modify('+1 day');
}
} else {
$message2 .= $GLOBALS["main_contact"]['name'] . " hat sich für den " . $input_date->format('d.m.Y') . " abgemeldet.\n";
$delete_query = "DELETE FROM events WHERE date_from = '" . $input_date->format('Y-m-d') . "' AND main_contact_id = '" . $GLOBALS['main_contact']['id'] . "'";
@mysqli_query($GLOBALS['mysql_con'], $delete_query);
$check_query = "SELECT events_type.id, events_type.sick_note FROM events_type WHERE events_type.sick_note = 1";
$check_result = mysqli_query($GLOBALS['mysql_con'], $check_query);
$check_row = mysqli_fetch_assoc($check_result);
$event_type_id = $check_row["id"];
$created_date = date(format: "Y-m-d H:i:s");
$modifiedDate = date(format: "Y-m-d H:i:s");
$insert_query = "INSERT INTO events (
date_from,
date_to,
main_contact_id,
event_type_id,
serial_code,
created_date,
modified_date,
all_day,
active
) VALUES (
'" . $date->format('Y-m-d') . "',
'" . $date->format('Y-m-d') . "',
'" . $GLOBALS['main_contact']['id'] . "',
'" . $event_type_id . "',
'" . $randomString . "',
'" . $created_date . "',
'" . $modifiedDate . "',
1,
1
)";
@mysqli_query($GLOBALS['mysql_con'], $insert_query);
}
if (isset($input_besprechung) && $input_ansprechpartner != "") {
$message2 .= "Die telefonische Besprechung zur Übergabe von Aufgaben und Terminen erfolgte mit " . $input_ansprechpartner . ".\n";
}
if (
mail_create(
$contactform_header["subject"],
$message2,
$contactform_header2["sender_email"],
$contactform_header2["recipient_email"],
$contactform_header2["sender_name"],
$contactform_header2["recipient_name"],
FALSE,
$file,
"",
"",
0,
"",
""
)
) {
mail_send();
unset($file);
clearstatcache();
$success = true;
$requestmassage .= $GLOBALS['tc']['contactform_success'];
foreach ($_POST as $key => $value) {
unset($_POST[$key]);
}
} else {
$error = TRUE;
$requestmassage .= $translation->get('not_send2');
}
}
}
if ($GLOBAL["ws_bestell_result"] == true) {
$query = "SELECT main_contact.email FROM main_contact JOIN main_contact_department ON main_contact.id = main_contact_department.main_contact_id AND main_role_id = '3' AND main_mandant_id = " . $GLOBALS["main_contact"]["current_mandant_id"];
$result = @mysqli_query($GLOBALS['mysql_con'], $query);
while ($email = @mysqli_fetch_array($result)) {
$contactform_header["recipient_email"] .= "; " . $email["email"];
}
}
$bccEmails = [];
$sender_id = (int) $GLOBALS['main_contact']['id'];
$sender_mandant_id = (int) $GLOBALS['main_contact']['current_mandant_id'];
$responsibleString = $contactform_header['responsible_person'] ?? '';
$responsibleIds = array_filter(array_map('intval', explode(',', $responsibleString)));
if (!empty($responsibleIds) && $sender_mandant_id) {
foreach ($responsibleIds as $pid) {
$pid = (int) $pid;
$check = mysqli_query($GLOBALS['mysql_con'], "
SELECT 1 FROM main_contact_department
WHERE main_contact_id = $pid
AND main_mandant_id = $sender_mandant_id
AND active = 1
LIMIT 1
");
if (mysqli_num_rows($check)) {
$emailRes = mysqli_query($GLOBALS['mysql_con'], "
SELECT email FROM main_contact
WHERE id = $pid AND email != ''
LIMIT 1
");
if ($emailRow = mysqli_fetch_assoc($emailRes)) {
$bccEmails[] = $emailRow['email'];
}
}
}
}
// Add mandant leaders to BCC if mandant_leader_in_cc is enabled
if ((int)($contactform_header["mandant_leader_in_cc"] ?? 0) === 1 && $sender_mandant_id > 0) {
$leadersRes = mysqli_query($GLOBALS['mysql_con'],
"SELECT mc.email FROM main_mandant_leader mml
INNER JOIN main_contact mc ON mc.id = mml.main_contact_id
WHERE mml.main_mandant_id = " . $sender_mandant_id . "
AND mc.active = 1 AND mc.email != ''
ORDER BY mml.sorting ASC"
);
while ($leader = mysqli_fetch_assoc($leadersRes)) {
$bccEmails[] = $leader['email'];
}
}
$bccString = implode(',', array_unique($bccEmails));
$formName = $contactform_header['description'] ?? '—';
$applicantName = $GLOBALS['main_contact']['name'] ?? 'Unbekannt';
$mandantName = '';
$mid = (int) $GLOBALS['main_contact']['current_mandant_id'];
if ($mid) {
$row = mysqli_fetch_assoc(
mysqli_query(
$GLOBALS['mysql_con'],
"SELECT description FROM main_mandant WHERE id = $mid LIMIT 1"
)
);
$mandantName = $row['description'] ?: 'ID ' . $mid;
}
$currentDt = date('d.m.Y H:i');
$formName = htmlspecialchars($contactform_header['description'] ?? '—', ENT_QUOTES);
$applicant = htmlspecialchars($GLOBALS['main_contact']['name'] ?? 'Unbekannt', ENT_QUOTES);
$mandant = '';
$mid = (int) $GLOBALS['main_contact']['current_mandant_id'];
if ($mid) {
$row = mysqli_fetch_assoc(
mysqli_query(
$GLOBALS['mysql_con'],
"SELECT description FROM main_mandant WHERE id = $mid LIMIT 1"
)
);
$mandant = htmlspecialchars($row['description'] ?: 'ID ' . $mid, ENT_QUOTES);
}
$currDt = date('d.m.Y H:i');
$metaHeader = "
<h2 style='margin-top:0;'>Neue Formular-Anfrage</h2>
<p style='margin:0 0 0 0;'><strong>Formular:</strong> $formName</p>
<p style='margin:0 0 0 0;'><strong>Erstellt von:</strong> $applicant</p>
<p style='margin:0 0 0 0;'><strong>Mandant:</strong> $mandant</p>
<p style='margin:0 0 0 0;'><strong>Datum:</strong> $currDt</p>
";
$message = $metaHeader . trim($message);
// fa wenn betreff ist leer
// $contactform_header["subject"] = trim((string)$contactform_header["subject"]);
// if ($contactform_header["subject"] === '') {
// $contactform_header["subject"] = 'Kontaktformular';
// }
if (
mail_create(
$contactform_header["subject"],
$message,
$contactform_header["sender_email"],
$contactform_header["recipient_email"],
$contactform_header["sender_name"],
$contactform_header["recipient_name"],
FALSE,
$file,
$bccString,
"",
0,
"",
$spid
)
) {
mail_send();
unset($file);
clearstatcache();
$success = true;
$requestmassage .= $GLOBALS['tc']['contactform_success'];
foreach ($_POST as $key => $value) {
unset($_POST[$key]);
}
} else {
$error = TRUE;
$requestmassage .= $translation->get('not_send3');
}
} else {
$error = TRUE;
$requestmassage .= $GLOBALS['tc']['mandatory_fields_error'];
if ($filetypeError === TRUE) {
$requestmassage .= $GLOBALS['tc']['contactform_error_php'];
}
}
$mail_query = mysqli_query($GLOBALS['mysql_con'], "SELECT id FROM main_mail_log ORDER BY id DESC LIMIT 1");
$mail_row = mysqli_fetch_assoc($mail_query);
$mail_id = $mail_row['id'] ?? 0;
foreach ($submission_data as $entry) {
$query_entry = "INSERT INTO contactform_entries (mail_id, line_id, value)
VALUES (
$mail_id,
" . intval($entry['field_id']) . ",
'" . mysqli_real_escape_string($GLOBALS['mysql_con'], $entry['value']) . "'
)";
mysqli_query($GLOBALS['mysql_con'], $query_entry);
}
if ($error || $success) {
if ($success) {
get_requestbox($requestmassage, "", "success");
} else {
get_requestbox($requestmassage, "");
}
}
}
// Helper-Funktion zur Berechtigungsprüfung
function require_permission($permissionCode)
{
if (
get_permission_state(
$permissionCode,
$GLOBALS['main_contact']['master_mandant_id'],
$GLOBALS['main_contact']['id']
) != 1
) {
echo "Sie haben nicht die notwendigen Berechtigungen, um diese Seite zu sehen.";
exit;
}
}
// Prüft, ob der User voller Wiki-Redakteur ist (r-wiki)
function is_wiki_redakteur(): bool
{
return get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1;
}
// Prüft, ob der User Betriebsrat-Wiki-Recht hat (r-wiki-br)
function is_wiki_betriebsrat(): bool
{
return get_permission_state('r-wiki-br', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1;
}
// Prüft, ob der User irgendeins der Wiki-Bearbeitungsrechte hat
function has_wiki_edit_permission(): bool
{
return is_wiki_redakteur() || is_wiki_betriebsrat();
}
function require_wiki_edit_permission(): void
{
if (!has_wiki_edit_permission()) {
echo "Sie haben nicht die notwendigen Berechtigungen, um diese Seite zu sehen.";
exit;
}
}
// Gibt alle Kategorie-IDs zurück, auf die Betriebsrat Zugriff hat:
// direkt betriebsrat_editable=1 sowie alle ihre Nachfahren im Baum (BFS nach unten).
function get_all_betriebsrat_accessible_category_ids(): array
{
$seeds = [];
$res = mysqli_query($GLOBALS['mysql_con'],
"SELECT id FROM knowledgecenter_categories_update WHERE betriebsrat_editable = 1");
while ($row = mysqli_fetch_assoc($res)) {
$seeds[] = (int)$row['id'];
}
if (empty($seeds)) {
return [];
}
$visited = [];
$queue = $seeds;
while (!empty($queue)) {
$current = array_shift($queue);
if (isset($visited[$current])) {
continue;
}
$visited[$current] = true;
$cRes = mysqli_query($GLOBALS['mysql_con'],
"SELECT child_category_id FROM knowledgecenter_category_links WHERE parent_category_id = $current");
while ($cRow = mysqli_fetch_assoc($cRes)) {
$cid = (int)$cRow['child_category_id'];
if (!isset($visited[$cid])) {
$queue[] = $cid;
}
}
}
return array_keys($visited);
}
// Prüft ob eine Kategorie (oder irgendein Vorfahre über knowledgecenter_category_links)
// betriebsrat_editable=1 hat. Läuft den Baum nach oben durch.
function is_category_betriebsrat_editable(int $categoryId): bool
{
$visited = [];
$queue = [$categoryId];
while (!empty($queue)) {
$current = array_shift($queue);
if (isset($visited[$current])) {
continue;
}
$visited[$current] = true;
$res = mysqli_query($GLOBALS['mysql_con'],
"SELECT betriebsrat_editable FROM knowledgecenter_categories_update WHERE id = $current LIMIT 1");
$row = mysqli_fetch_assoc($res);
if ($row && (int)$row['betriebsrat_editable'] === 1) {
return true;
}
$pRes = mysqli_query($GLOBALS['mysql_con'],
"SELECT parent_category_id FROM knowledgecenter_category_links WHERE child_category_id = $current");
while ($pRow = mysqli_fetch_assoc($pRes)) {
$pid = (int)$pRow['parent_category_id'];
if (!isset($visited[$pid])) {
$queue[] = $pid;
}
}
}
return false;
}
// Switch-Case für die verschiedenen Aktionen
$action = $_GET["action"] ?? "List";
switch ($action) {
case "Installation":
require_permission('r-wiki');
require_once(__DIR__ . '/../Installation/Installation.php');
break;
case "Categories":
// Kategorieverwaltung nur für vollständige Redakteure
if (isset($_GET['detail'])) {
require_permission('r-wiki');
require_once(__DIR__ . '/../Views/categories_cardform.php');
} else {
require_permission('r-wiki');
require_once(__DIR__ . '/../Views/categories_listform.php');
}
break;
case "PostList":
// Betriebsrat darf die Post-Liste sehen (gefiltert auf seine Kategorien)
require_wiki_edit_permission();
require_once(__DIR__ . '/../Views/post_listform.php');
break;
case "Post":
require_once(__DIR__ . '/../Views/post_cardform.php');
break;
case "Import":
require_permission('r-wiki');
require_once(__DIR__ . '/../Import/Import.php');
break;
case "NewPost":
// Betriebsrat darf neue Posts erstellen (nur in freigegebenen Kategorien)
require_wiki_edit_permission();
$_GET['new'] = 1;
require_once(__DIR__ . '/../Views/post_cardform.php');
break;
case "List":
default:
require_once(__DIR__ . '/../Views/categories_posts_listform.php');
break;
}
require_once(__DIR__ . '/../Script/Script.php');
require_once(__DIR__ . '/../CSS/CSS.php');
?>

View File

@@ -0,0 +1,28 @@
<script src="https://code.jquery.com/jquery-3.7.1.js"></script>
<script src="https://code.jquery.com/ui/1.14.1/jquery-ui.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify"></script>
<script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.polyfills.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.css" rel="stylesheet" type="text/css" />
<!-- Quill 2.0.0dev.3 -->
<link
href="https://cdn.jsdelivr.net/npm/quill@2.0.0-dev.3/dist/quill.snow.css"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.0-dev.3/dist/quill.min.js"></script>
<!-- Better Table CSS -->
<link
href="https://cdn.jsdelivr.net/npm/quill-better-table@1.2.10/dist/quill-better-table.css"
rel="stylesheet"
/>
<!-- Better Table JS -->
<script src="https://cdn.jsdelivr.net/npm/quill-better-table@1.2.10/dist/quill-better-table.min.js"></script>
<!-- Shared Quill helpers: Drag-Resize-Modul für Bilder.
Aktivierung pro Editor-Instanz via `modules: { imageResize: true }`. -->
<link rel="stylesheet" href="/module/shared_components/quill/quill_resize.css">
<script src="/module/shared_components/quill/quill_resize.js"></script>
<script src="/module/shared_components/quill/toolbar_presets.js"></script>
<script src="/mysyde/admin/admin.js"></script>

View File

@@ -0,0 +1,264 @@
<h1>Import-Dashboard</h1>
<hr>
<a href="?action=Import&reset_import=1">Tabellen leeren (Reset) </a>
<hr>
<a href="?action=Import&cat_import=1">Kategorien importieren </a>
<hr>
<a href="?action=Import&post_import=1">Beiträge importieren </a>
<hr>
<?php
// ini_set('display_errors', 1);
// error_reporting(E_ALL);
/**
* Funktion zum Truncaten (Leeren) aller relevanten Tabellen für den Import-Test.
* Dabei werden vorübergehend die Foreign-Key-Prüfungen deaktiviert.
*/
function truncate_import_tables()
{
die("Die Funktion truncate_import_tables() ist deaktiviert, um Datenverlust zu vermeiden.");
// Tabellen, die geleert werden sollen
$tables = [
'knowledgecenter_category_links',
'knowledgecenter_category_post',
'knowledgecenter_category_translations',
'knowledgecenter_category_mandant',
'knowledgecenter_category_department',
'knowledgecenter_category_role',
'knowledgecenter_categories_update',
'knowledgecenter_post_version_contacts',
'knowledgecenter_post_versions',
'knowledgecenter_post_role',
'knowledgecenter_post_files',
'knowledgecenter_post_department',
'knowledgecenter_post_mandant',
'knowledgecenter_post_contacts',
'knowledgecenter_post_comments',
'knowledgecenter_posts'
];
// Foreign-Key-Prüfungen deaktivieren
mysqli_query($GLOBALS['mysql_con'], "SET FOREIGN_KEY_CHECKS = 0");
foreach ($tables as $table) {
$query = "TRUNCATE TABLE $table";
if (mysqli_query($GLOBALS['mysql_con'], $query)) {
echo "Tabelle <strong>$table</strong> wurde erfolgreich geleert.<br>";
} else {
echo "Fehler beim Leeren der Tabelle <strong>$table</strong>: " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
}
}
// Foreign-Key-Prüfungen wieder aktivieren
mysqli_query($GLOBALS['mysql_con'], "SET FOREIGN_KEY_CHECKS = 1");
}
/**
* Rekursive Funktion zum Importieren von Kategorien.
* Für jeden Unterordner wird sofern noch nicht vorhanden eine Kategorie in
* knowledgecenter_categories_update und eine Übersetzung in knowledgecenter_category_translations (language_id = 1) angelegt.
* Bei Unterordnern wird zudem in knowledgecenter_category_links eine Parent-Child-Verknüpfung erstellt.
*
* @param string $folder Absoluter Pfad des aktuellen Ordners.
* @param int|null $parentCategoryId Falls vorhanden, die Kategorie-ID des Elternordners.
*/
function import_categories($folder, $parentCategoryId = null)
{
if (!is_dir($folder)) {
echo "Ordner '$folder' existiert nicht.<br>";
return;
}
$folderName = basename($folder);
$folderNameEscaped = mysqli_real_escape_string($GLOBALS['mysql_con'], $folderName);
// Prüfen, ob eine Kategorie mit diesem Titel (language_id = 1) bereits existiert
$query = "SELECT c.id
FROM knowledgecenter_categories_update c
JOIN knowledgecenter_category_translations t ON c.id = t.category_id
WHERE t.language_id = 1 AND t.title = '$folderNameEscaped' LIMIT 1";
$result = mysqli_query($GLOBALS['mysql_con'], $query);
if ($result && mysqli_num_rows($result) > 0) {
$row = mysqli_fetch_assoc($result);
$categoryId = $row['id'];
echo "Kategorie '$folderName' existiert bereits (ID: $categoryId).<br>";
} else {
$sql_category = "INSERT INTO knowledgecenter_categories_update (sorting, state, modified_at, modified_by, created_at)
VALUES (0, 1, NOW(), 'import', NOW())";
if (!mysqli_query($GLOBALS['mysql_con'], $sql_category)) {
echo "Fehler beim Erstellen der Kategorie '$folderName': " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
return;
}
$categoryId = mysqli_insert_id($GLOBALS['mysql_con']);
$sql_trans = "INSERT INTO knowledgecenter_category_translations (category_id, language_id, title, created_at)
VALUES ($categoryId, 1, '$folderNameEscaped', NOW())";
if (!mysqli_query($GLOBALS['mysql_con'], $sql_trans)) {
echo "Fehler beim Einfügen der Kategorie-Übersetzung für '$folderName': " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
}
echo "Kategorie '$folderName' wurde erstellt (ID: $categoryId).<br>";
}
if ($parentCategoryId !== null) {
$query_link = "SELECT id FROM knowledgecenter_category_links WHERE parent_category_id = $parentCategoryId AND child_category_id = $categoryId LIMIT 1";
$result_link = mysqli_query($GLOBALS['mysql_con'], $query_link);
if ($result_link && mysqli_num_rows($result_link) == 0) {
$sql_link = "INSERT INTO knowledgecenter_category_links (parent_category_id, child_category_id, sorting, created_at)
VALUES ($parentCategoryId, $categoryId, 0, NOW())";
if (!mysqli_query($GLOBALS['mysql_con'], $sql_link)) {
echo "Fehler beim Verknüpfen der Kategorie '$folderName' mit Eltern ($parentCategoryId): " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
}
}
}
// Unterordner verarbeiten
$subDirs = glob($folder . "/*", GLOB_ONLYDIR);
if ($subDirs !== false) {
foreach ($subDirs as $subDir) {
import_categories($subDir, $categoryId);
}
}
}
/**
* Rekursive Funktion zum Importieren von Beiträgen.
* In jedem Ordner werden alle Dateien als Beiträge importiert.
* Für jede Datei wird der Dateiname (ohne Erweiterung) als Titel verwendet und
* als Teaser "Hier finden Sie die Informationen zu [Dateiname]" gesetzt.
* Anschließend wird der Beitrag in knowledgecenter_posts und knowledgecenter_post_versions (language_id = 1, Version 1) angelegt
* und in knowledgecenter_category_post mit der zugehörigen Kategorie verknüpft.
* Zusätzlich wird ein Eintrag in knowledgecenter_post_files für die Datei erstellt.
*
* @param string $folder Absoluter Pfad des aktuellen Ordners.
*/
function import_posts($folder)
{
if (!is_dir($folder)) {
return;
}
// Ermitteln der zugehörigen Kategorie anhand des Ordnernamens
$folderName = basename($folder);
$folderNameEscaped = mysqli_real_escape_string($GLOBALS['mysql_con'], $folderName);
$query = "SELECT c.id
FROM knowledgecenter_categories_update c
JOIN knowledgecenter_category_translations t ON c.id = t.category_id
WHERE t.language_id = 1 AND t.title = '$folderNameEscaped' LIMIT 1";
$result = mysqli_query($GLOBALS['mysql_con'], $query);
if ($result && mysqli_num_rows($result) > 0) {
$row = mysqli_fetch_assoc($result);
$categoryId = $row['id'];
} else {
echo "Keine Kategorie für '$folderName' gefunden. Bitte zuerst Kategorien importieren.<br>";
return;
}
// Alle Dateien im aktuellen Ordner als Beiträge importieren
$files = glob($folder . "/*");
if ($files !== false) {
foreach ($files as $item) {
if (is_file($item)) {
$fileName = basename($item);
$fileTitle = pathinfo($fileName, PATHINFO_FILENAME); // Dateiname ohne Erweiterung
$titleEscaped = mysqli_real_escape_string($GLOBALS['mysql_con'], $fileTitle);
$teaser = "Hier finden Sie die Informationen zu $fileTitle";
$teaserEscaped = mysqli_real_escape_string($GLOBALS['mysql_con'], $teaser);
// Überprüfen, ob bereits ein Beitrag mit diesem Titel existiert
$checkQuery = "SELECT pv.id
FROM knowledgecenter_post_versions pv
JOIN knowledgecenter_posts p ON pv.post_id = p.id
WHERE pv.language_id = 1 AND pv.title = '$titleEscaped' LIMIT 1";
$checkResult = mysqli_query($GLOBALS['mysql_con'], $checkQuery);
if ($checkResult && mysqli_num_rows($checkResult) > 0) {
echo "Beitrag '$fileTitle' existiert bereits. Überspringe.<br>";
continue;
}
// Beitrag erstellen
$sql_post = "INSERT INTO knowledgecenter_posts (created_at, modified_at) VALUES (NOW(), NOW())";
if (!mysqli_query($GLOBALS['mysql_con'], $sql_post)) {
echo "Fehler beim Erstellen des Beitrags für Datei '$fileName': " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
continue;
}
$newPostId = mysqli_insert_id($GLOBALS['mysql_con']);
// Beitrag-Version anlegen (language_id = 1, Version 1)
$sql_post_version = "INSERT INTO knowledgecenter_post_versions
(post_id, version_number, language_id, title, teaser, content, author_id, created_at)
VALUES ($newPostId, 1, 1, '$titleEscaped', '$teaserEscaped', '', 0, NOW())";
if (!mysqli_query($GLOBALS['mysql_con'], $sql_post_version)) {
echo "Fehler beim Erstellen der Beitragsversion für Datei '$fileTitle': " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
}
// Beitrag mit der Kategorie verknüpfen
$sql_link_post = "INSERT INTO knowledgecenter_category_post (category_id, post_id) VALUES ($categoryId, $newPostId)";
if (!mysqli_query($GLOBALS['mysql_con'], $sql_link_post)) {
echo "Fehler beim Verknüpfen des Beitrags $newPostId mit Kategorie $categoryId: " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
}
// Eintrag in die Files-Tabelle erstellen
// Ermitteln des relativen Pfads relativ zum Document Root:
$docRoot = realpath($_SERVER['DOCUMENT_ROOT']);
$relativePath = str_replace($docRoot, '', realpath($item));
// Als Beschreibung verwenden wir den Dateinamen (inklusive Erweiterung)
$descEscaped = mysqli_real_escape_string($GLOBALS['mysql_con'], $fileName);
$pathEscaped = mysqli_real_escape_string($GLOBALS['mysql_con'], $relativePath);
$sql_file = "INSERT INTO knowledgecenter_post_files (post_id, description, path, created_at)
VALUES ($newPostId, '$descEscaped', '$pathEscaped', NOW())";
if (!mysqli_query($GLOBALS['mysql_con'], $sql_file)) {
echo "Fehler beim Einfügen in die Files-Tabelle für Datei '$fileName': " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
}
echo "Beitrag '$fileTitle' (ID: $newPostId) in Kategorie '$folderName' importiert.<br>";
}
}
}
// Unterordner rekursiv verarbeiten
$subDirs = glob($folder . "/*", GLOB_ONLYDIR);
if ($subDirs !== false) {
foreach ($subDirs as $subDir) {
import_posts($subDir);
}
}
}
// Hauptlogik: Prüfe die GET-Parameter zur Steuerung der Aktionen
// Da im Link auch ?action=Import gesetzt ist, können wir dies ignorieren und nur reset, cat_import, post_import prüfen.
// Reset: Tabellen leeren
if (isset($_GET['reset_import']) && $_GET['reset_import'] == "1") {
echo "<h1>Reset Import gestartet</h1>";
truncate_import_tables();
echo "<h1>Reset Import beendet</h1>";
exit;
}
// Kategorien importieren: GET-Parameter "cat_import" verwenden
if (isset($_GET['cat_import']) && $_GET['cat_import'] == "1") {
$importFolder = realpath($_SERVER['DOCUMENT_ROOT'] . '/userdata/knowledgecenter_update');
if (!$importFolder) {
die("Der Import-Ordner wurde nicht gefunden.");
}
echo "<h1>Kategorien-Import gestartet</h1>";
import_categories($importFolder);
echo "<h1>Kategorien-Import beendet</h1>";
exit;
}
// Beiträge importieren: GET-Parameter "post_import" verwenden
if (isset($_GET['post_import']) && $_GET['post_import'] == "1") {
$importFolder = realpath($_SERVER['DOCUMENT_ROOT'] . '/userdata/knowledgecenter_update');
if (!$importFolder) {
die("Der Import-Ordner wurde nicht gefunden.");
}
echo "<h1>Beiträge-Import gestartet</h1>";
import_posts($importFolder);
echo "<h1>Beiträge-Import beendet</h1>";
exit;
}
?>

View File

@@ -0,0 +1,444 @@
<?php
function echoSuccess($message)
{
echo "<div style='background-color:#e6ffed;color:#267b3c;padding:15px;
border:1px solid #b2dfdb;border-radius:5px;margin:20px;
font-family:sans-serif;'>✅ $message</div>";
}
function echoError($message)
{
echo "<div style='background-color:#ffe6e6;color:#a33;padding:15px;
border:1px solid #f5c2c2;border-radius:5px;margin:20px;
font-family:sans-serif;'>❌ $message</div>";
}
// Beispielhafte DB-Verbindung (nur Dummy für Kontext)
if (!isset($GLOBALS['mysql_con'])) {
echoError("Keine Datenbankverbindung gefunden.");
exit;
}
// Tabellen-Erstellung
function create_tables()
{
$allSuccess = true;
$queries = [
// Tabellenname => SQL
"knowledgecenter_posts" => "CREATE TABLE IF NOT EXISTS knowledgecenter_posts (
id INT AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by VARCHAR(255) COMMENT 'Name des Main Contact',
preview_inline TINYINT(1) NOT NULL DEFAULT 1 COMMENT '0 = keine Inline-Vorschau, 1 = Inline-Vorschau',
background_color VARCHAR(9) NOT NULL DEFAULT '#FFFFFF',
text_color VARCHAR(9) NOT NULL DEFAULT '#000000'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_post_contacts" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_contacts (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
main_contact_id INT NOT NULL,
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_post_mandant" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_mandant (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
mandant_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_post_department" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_department (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
department_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_post_files" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_files (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
description VARCHAR(255),
path VARCHAR(500),
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_post_role" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_role (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
role_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_post_versions" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
version_number INT NOT NULL,
language_id INT NOT NULL,
title VARCHAR(255),
image VARCHAR(255),
teaser TEXT,
content LONGTEXT,
author_id INT,
state TINYINT DEFAULT 1,
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP,
valid_from DATETIME NULL,
valid_until DATETIME NULL,
background_color VARCHAR(9) NOT NULL DEFAULT '#FFFFFF',
text_color VARCHAR(9) NOT NULL DEFAULT '#000000',
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_post_version_contacts" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_version_contacts (
id INT AUTO_INCREMENT PRIMARY KEY,
version_id INT NOT NULL,
contact_id INT NOT NULL,
FOREIGN KEY (version_id) REFERENCES knowledgecenter_post_versions(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_categories_update" => "CREATE TABLE IF NOT EXISTS knowledgecenter_categories_update (
id INT AUTO_INCREMENT PRIMARY KEY,
sorting INT DEFAULT 0,
state TINYINT DEFAULT 1,
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by VARCHAR(255) COMMENT 'Name des Main Contact',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_category_translations" => "CREATE TABLE IF NOT EXISTS knowledgecenter_category_translations (
id INT AUTO_INCREMENT PRIMARY KEY,
category_id INT NOT NULL,
language_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
image VARCHAR(255),
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES knowledgecenter_categories_update(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_category_post" => "CREATE TABLE IF NOT EXISTS knowledgecenter_category_post (
id INT AUTO_INCREMENT PRIMARY KEY,
category_id INT NOT NULL,
post_id INT NOT NULL,
FOREIGN KEY (category_id) REFERENCES knowledgecenter_categories_update(id)
ON DELETE CASCADE,
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_category_mandant" => "CREATE TABLE IF NOT EXISTS knowledgecenter_category_mandant (
id INT AUTO_INCREMENT PRIMARY KEY,
category_id INT NOT NULL,
mandant_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES knowledgecenter_categories_update(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_category_department" => "CREATE TABLE IF NOT EXISTS knowledgecenter_category_department (
id INT AUTO_INCREMENT PRIMARY KEY,
category_id INT NOT NULL,
department_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES knowledgecenter_categories_update(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_category_role" => "CREATE TABLE IF NOT EXISTS knowledgecenter_category_role (
id INT AUTO_INCREMENT PRIMARY KEY,
category_id INT NOT NULL,
role_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES knowledgecenter_categories_update(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_post_comments" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_comments (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
parent_id INT DEFAULT NULL,
text TEXT NOT NULL,
author_id INT NOT NULL COMMENT 'main_contact_id',
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES knowledgecenter_post_comments(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
"knowledgecenter_category_links" => "CREATE TABLE IF NOT EXISTS knowledgecenter_category_links (
id INT AUTO_INCREMENT PRIMARY KEY,
parent_category_id INT NOT NULL,
child_category_id INT NOT NULL,
sorting INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_category_id) REFERENCES knowledgecenter_categories_update(id)
ON DELETE CASCADE,
FOREIGN KEY (child_category_id) REFERENCES knowledgecenter_categories_update(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"
];
foreach ($queries as $name => $sql) {
if (mysqli_query($GLOBALS['mysql_con'], $sql)) {
echoSuccess("Tabelle <strong>$name</strong> wurde erfolgreich erstellt (oder existierte bereits).");
} else {
echoError("Fehler beim Erstellen der Tabelle <strong>$name</strong>: " . mysqli_error($GLOBALS['mysql_con']));
$allSuccess = false;
}
}
return $allSuccess;
}
// Ausführen der Tabellen-Erstellung
if (create_tables()) {
echoSuccess("✅ Alle Tabellen erfolgreich erstellt!");
} else {
echoError("❌ Es gab Fehler beim Erstellen einzelner Tabellen.");
}
// Jetzt die zusätzlichen Indexe mittels ALTER TABLE setzen
$indexQueries = [
// knowledgecenter_posts
"ALTER TABLE knowledgecenter_posts ADD INDEX idx_created_at (created_at);",
"ALTER TABLE knowledgecenter_posts ADD INDEX idx_modified_at (modified_at);",
"ALTER TABLE knowledgecenter_posts ADD INDEX idx_modified_by (modified_by);",
// knowledgecenter_post_contacts
"ALTER TABLE knowledgecenter_post_contacts ADD INDEX idx_post_id (post_id);",
"ALTER TABLE knowledgecenter_post_contacts ADD INDEX idx_main_contact_id (main_contact_id);",
// knowledgecenter_post_mandant
"ALTER TABLE knowledgecenter_post_mandant ADD INDEX idx_post_mandant (post_id, mandant_id);",
// knowledgecenter_post_department
"ALTER TABLE knowledgecenter_post_department ADD INDEX idx_post_department (post_id, department_id);",
// knowledgecenter_post_files
"ALTER TABLE knowledgecenter_post_files ADD INDEX idx_post_id (post_id);",
// knowledgecenter_post_role
"ALTER TABLE knowledgecenter_post_role ADD INDEX idx_post_role (post_id, role_id);",
// knowledgecenter_post_versions
"ALTER TABLE knowledgecenter_post_versions ADD INDEX idx_post_id (post_id);",
"ALTER TABLE knowledgecenter_post_versions ADD INDEX idx_post_language (post_id, language_id);",
// knowledgecenter_post_version_contacts
"ALTER TABLE knowledgecenter_post_version_contacts ADD INDEX idx_version_contact (version_id, contact_id);",
// knowledgecenter_categories_update
"ALTER TABLE knowledgecenter_categories_update ADD INDEX idx_state_sorting (state, sorting);",
"ALTER TABLE knowledgecenter_categories_update ADD INDEX idx_modified_at (modified_at);",
"ALTER TABLE knowledgecenter_categories_update ADD INDEX idx_modified_by (modified_by);",
// knowledgecenter_category_translations
"ALTER TABLE knowledgecenter_category_translations ADD INDEX idx_category_language (category_id, language_id);",
// knowledgecenter_category_post
"ALTER TABLE knowledgecenter_category_post ADD INDEX idx_category_post (category_id, post_id);",
// knowledgecenter_category_mandant
"ALTER TABLE knowledgecenter_category_mandant ADD INDEX idx_category_mandant (category_id, mandant_id);",
// knowledgecenter_category_department
"ALTER TABLE knowledgecenter_category_department ADD INDEX idx_category_department (category_id, department_id);",
// knowledgecenter_category_role
"ALTER TABLE knowledgecenter_category_role ADD INDEX idx_category_role (category_id, role_id);",
// knowledgecenter_post_comments
"ALTER TABLE knowledgecenter_post_comments ADD INDEX idx_post_id (post_id);",
"ALTER TABLE knowledgecenter_post_comments ADD INDEX idx_parent_id (parent_id);",
// knowledgecenter_category_links
"ALTER TABLE knowledgecenter_category_links ADD INDEX idx_parent_child (parent_category_id, child_category_id);"
];
foreach ($indexQueries as $query) {
if (mysqli_query($GLOBALS['mysql_con'], $query)) {
echoSuccess("Index erfolgreich erstellt: " . htmlspecialchars($query));
} else {
echoError("Fehler beim Erstellen des Index: " . mysqli_error($GLOBALS['mysql_con']) . " - Query: " . htmlspecialchars($query));
}
}
// Überprüfen, ob die Spalte "post_id" in der Tabelle "main_certificate" existiert
$checkQuery = "SHOW COLUMNS FROM main_certificate LIKE 'post_id'";
$checkResult = mysqli_query($GLOBALS['mysql_con'], $checkQuery);
if ($checkResult && mysqli_num_rows($checkResult) == 0) {
// Spalte existiert nicht hinzufügen
$alterQuery = "ALTER TABLE main_certificate ADD COLUMN post_id INT NOT NULL"; // oder NULL, je nach Anforderung
if (mysqli_query($GLOBALS['mysql_con'], $alterQuery)) {
echoSuccess("Spalte 'post_id' in Tabelle main_certificate erfolgreich hinzugefügt.");
} else {
echoError("Fehler beim Hinzufügen der Spalte 'post_id': " . mysqli_error($GLOBALS['mysql_con']));
}
} else {
echoSuccess("Spalte 'post_id' existiert bereits in main_certificate.");
}
// Array mit den Permissions, die hinzugefügt werden sollen
$permissions = [
[
'description' => 'Darf Wiki Modul installieren',
'code' => 'install_admin_kc',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf Wiki Inhalte importieren',
'code' => 'import_admin_kc',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf Wiki Beitragsliste sehen',
'code' => 'view_admin_kc_post_list',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf Wiki Kategorie Admin Listen Items löschen',
'code' => 'delete_admin_kc_category_item',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf Wiki Kategorie Admin Liste sehen',
'code' => 'view_admin_kc_category_list',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf Wiki Kategorien erstellen',
'code' => 'create_admin_kc_category',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf Wiki Kategorien bearbeiten',
'code' => 'edit_admin_kc_category',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf neuen Wiki Beitrag erstellen',
'code' => 'create_admin_kc_post',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf Wiki Beitragsversion ändern',
'code' => 'edit_admin_kc_post_version',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf neue Wiki Beitragsversion erstellen',
'code' => 'create_admin_kc_post_version',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf Wiki Beitragsversion löschen',
'code' => 'delete_admin_kc_post_version',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf Wiki Beitragsdateien löschen',
'code' => 'delete_admin_kc_post_file',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf Wiki Beitragsdateien hinzufügen',
'code' => 'add_admin_kc_post_file',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf Wiki Beitrags Ansprechpartner verwalten',
'code' => 'manage_admin_kc_post_contacts',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf Wiki Beitrags Einstellungen sehen',
'code' => 'view_admin_kc_post_settings',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
],
[
'description' => 'Darf Wiki Beitrags Einstellungen speichern',
'code' => 'save_admin_kc_post_settings',
'short_text' => '',
'only_main_mandant' => 1,
'default_active' => 0
]
];
// Iteriere über das Array und füge jede Permission ein:
foreach ($permissions as $perm) {
// Stelle sicher, dass Zeichenketten richtig escaped werden:
$description = mysqli_real_escape_string($GLOBALS['mysql_con'], $perm['description']);
$code = mysqli_real_escape_string($GLOBALS['mysql_con'], $perm['code']);
$short_text = mysqli_real_escape_string($GLOBALS['mysql_con'], $perm['short_text']);
$only_main_mandant = (int) $perm['only_main_mandant'];
$default_active = (int) $perm['default_active'];
// Zuerst prüfen, ob eine Permission mit diesem Code bereits existiert.
$checkSql = "SELECT id FROM main_permission WHERE code = '$code' LIMIT 1";
$checkResult = mysqli_query($GLOBALS['mysql_con'], $checkSql);
if ($checkResult && mysqli_num_rows($checkResult) > 0) {
// Falls vorhanden, kannst Du eine Erfolgsmeldung anzeigen oder den Eintrag überspringen.
echoSuccess("Permission '$description' existiert bereits.");
continue; // Weitere Verarbeitung überspringen.
}
// Falls nicht vorhanden, führe das INSERT aus:
$insertSql = "INSERT INTO `main_permission` (`id`, `description`, `code`, `short_text`, `only_main_mandant`, `default_active`)
VALUES (NULL, '$description', '$code', '$short_text', '$only_main_mandant', '$default_active')";
if (mysqli_query($GLOBALS['mysql_con'], $insertSql)) {
echoSuccess("Permission '$description' wurde erfolgreich hinzugefügt.");
} else {
echoError("Fehler beim Hinzufügen der Permission '$description': " . mysqli_error($GLOBALS['mysql_con']));
}
}
?>

View File

@@ -0,0 +1,86 @@
<?php
// Einmalige Migration: legt in jedem bestehenden Beitragsordner unter
// /userdata/knowledgecenter_update/posts/<hash>/ einen Symlink "shared"
// an, der auf ../../shared zeigt.
//
// Idempotent (bereits vorhandene Einträge werden übersprungen) und
// CLI-only — niemals direkt per HTTP aufrufbar.
//
// Usage:
// php Installation/add_shared_symlinks.php --dry-run
// php Installation/add_shared_symlinks.php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('CLI only');
}
$dryRun = in_array('--dry-run', $argv, true);
$postsDir = realpath(__DIR__ . '/../../../userdata/knowledgecenter_update/posts');
$sharedDir = realpath(__DIR__ . '/../../../userdata/knowledgecenter_update/shared');
if ($postsDir === false) {
fwrite(STDERR, "FEHLER: posts/-Verzeichnis nicht gefunden\n");
exit(2);
}
if ($sharedDir === false) {
fwrite(STDERR, "FEHLER: shared/-Verzeichnis nicht gefunden — bitte erst anlegen\n");
exit(2);
}
echo "Mode: " . ($dryRun ? 'DRY-RUN (keine Änderungen)' : 'LIVE') . "\n";
echo "Posts: $postsDir\n";
echo "Shared: $sharedDir\n\n";
$dirs = glob($postsDir . '/*', GLOB_ONLYDIR);
if ($dirs === false) {
fwrite(STDERR, "FEHLER: glob() lieferte false\n");
exit(2);
}
$created = 0;
$skipped = 0;
$collisions = 0;
$failed = 0;
foreach ($dirs as $dir) {
$linkPath = $dir . '/shared';
$name = basename($dir);
if (is_link($linkPath)) {
$skipped++;
continue;
}
if (file_exists($linkPath)) {
// gleichnamiger echter Ordner/Datei — nicht überschreiben
echo "[KOLLISION] $name/shared existiert bereits als Ordner/Datei — übersprungen\n";
$collisions++;
continue;
}
if ($dryRun) {
echo "[DRY-RUN] würde anlegen: $name/shared -> ../../shared\n";
$created++;
continue;
}
if (@symlink('../../shared', $linkPath)) {
$created++;
} else {
$err = error_get_last();
echo "[FEHLER] $name/shared konnte nicht angelegt werden: " . ($err['message'] ?? 'unbekannt') . "\n";
$failed++;
}
}
echo "\n=== Zusammenfassung ===\n";
echo "Beitragsordner gesamt: " . count($dirs) . "\n";
echo "Symlinks " . ($dryRun ? 'geplant' : 'angelegt') . ": $created\n";
echo "Bereits vorhanden: $skipped\n";
echo "Kollisionen: $collisions\n";
echo "Fehlgeschlagen: $failed\n";
exit($failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,62 @@
<?php
/**
* Zunächst: Holen der User-Daten (Mandanten, Abteilungen, Rollen, Einrichtungen, Fachbereiche)
*/
$currentUserId = $GLOBALS["main_contact"]["id"] ?? 0;
$userMandants = [];
$userDepartments = [];
$userRoles = [];
$userEinrichts = [];
$userFachbereiche = [];
if ($currentUserId) {
$query = "SELECT main_mandant_id, main_department_id, main_role_id, main_einricht_id, main_bereich_id
FROM main_contact_department
WHERE main_contact_id = $currentUserId";
$result = mysqli_query($GLOBALS['mysql_con'], $query);
while ($row = mysqli_fetch_assoc($result)) {
// Filtere 0 aus 0 gilt als "keine Zuordnung"
if ($row['main_mandant_id'] != 0 && !in_array($row['main_mandant_id'], $userMandants)) {
$userMandants[] = $row['main_mandant_id'];
}
if ($row['main_department_id'] != 0 && !in_array($row['main_department_id'], $userDepartments)) {
$userDepartments[] = $row['main_department_id'];
}
if ($row['main_role_id'] != 0 && !in_array($row['main_role_id'], $userRoles)) {
$userRoles[] = $row['main_role_id'];
}
if ($row['main_einricht_id'] != 0 && !in_array($row['main_einricht_id'], $userEinrichts)) {
$userEinrichts[] = $row['main_einricht_id'];
}
if ($row['main_bereich_id'] != 0 && !in_array($row['main_bereich_id'], $userFachbereiche)) {
$userFachbereiche[] = $row['main_bereich_id'];
}
}
}
/**
* Für den LEFT JOIN-Ansatz bereiten wir die erlaubten Werte als
* kommaseparierte Strings vor.
*/
$allowedMandants = !empty($userMandants) ? implode(',', $userMandants) : 'NULL';
$allowedDepartments = !empty($userDepartments) ? implode(',', $userDepartments) : 'NULL';
$allowedRoles = !empty($userRoles) ? implode(',', $userRoles) : 'NULL';
$allowedEinrichts = !empty($userEinrichts) ? implode(',', $userEinrichts) : 'NULL';
$allowedFachbereiche = !empty($userFachbereiche) ? implode(',', $userFachbereiche) : 'NULL';
// Für den globalen Zugriff kannst Du auch die Variablen in $GLOBALS speichern:
$GLOBALS['allowedMandants'] = $allowedMandants;
$GLOBALS['allowedDepartments'] = $allowedDepartments;
$GLOBALS['allowedRoles'] = $allowedRoles;
$GLOBALS['allowedEinrichts'] = $allowedEinrichts;
$GLOBALS['allowedFachbereiche'] = $allowedFachbereiche;
// echo "<pre>";
// echo "Mandanten: " . $allowedMandants . "\n";
// echo "Abteilungen: " . $allowedDepartments . "\n";
// echo "Rollen: " . $allowedRoles . "\n";
// echo "</pre>";

View File

@@ -0,0 +1,228 @@
// custom.js Files Gallery Selection & Popup Script
// Prüfen, ob dieses Fenster ein Popup (d.h. ein Child-Fenster) ist
if (window.opener) {
// URL-Parameter auslesen und in localStorage speichern
const urlParams = new URLSearchParams(window.location.search);
let input_id = urlParams.get('input_id') || "";
let image_id = urlParams.get('image_id') || "";
let type = urlParams.get('type') || "";
let mode = urlParams.get('mode') || "";
let saveajax = urlParams.get('saveajax') || "";
let kc_lock_path = urlParams.get('kc_lock_path') || "";
if (urlParams.has('input_id')) {
if (input_id) {
localStorage.setItem('input_id', input_id);
} else {
localStorage.removeItem('input_id');
}
}
if (urlParams.has('image_id')) {
if (image_id) {
localStorage.setItem('image_id', image_id);
} else {
localStorage.removeItem('image_id');
}
}
if (urlParams.has('type')) {
if (type) {
localStorage.setItem('type', type);
} else {
localStorage.removeItem('type');
}
}
if (urlParams.has('mode')) {
if (mode) {
localStorage.setItem('mode', mode);
} else {
localStorage.removeItem('mode');
}
}
if (urlParams.has('saveajax')) {
if (saveajax) {
localStorage.setItem('saveajax', saveajax);
} else {
localStorage.removeItem('saveajax');
}
}
if (urlParams.has('kc_lock_path')) {
if (kc_lock_path) {
localStorage.setItem('kc_lock_path', kc_lock_path);
} else {
localStorage.removeItem('kc_lock_path');
}
}
// Werte aus localStorage wieder auslesen
input_id = localStorage.getItem('input_id');
image_id = localStorage.getItem('image_id');
type = localStorage.getItem('type');
mode = localStorage.getItem('mode');
saveajax = localStorage.getItem('saveajax');
kc_lock_path = localStorage.getItem('kc_lock_path') || "";
function normalizePath(path) {
if (!path) return "";
const cleaned = String(path).replace(/^\/+/, '');
const lockPath = String(kc_lock_path || '').replace(/^\/+|\/+$/g, '');
if (!lockPath) {
return cleaned;
}
if (cleaned === lockPath || cleaned.indexOf(lockPath + '/') === 0) {
return cleaned;
}
return lockPath + '/' + cleaned;
}
function normalizePaths(paths) {
if (!paths) return "";
return String(paths)
.split(',')
.map(part => normalizePath(part.trim()))
.filter(Boolean)
.join(', ');
}
/**
* Übergibt die gewählten Pfade an das Parent-Fenster und schließt das Popup.
* @param {string} paths Kommagetrennter Pfad(e)
* @param {string} input_id ID des Input-Felds im Parent
* @param {string} image_id ID des Bild-Elements im Parent
*/
function sendDataAndClose(paths, input_id, image_id) {
const normalizedPaths = normalizePaths(paths);
console.log("Übergebe Pfade:", normalizedPaths);
if (!window.opener) return;
// Setze den Pfad oder die Pfade im Input-Feld
const input = window.opener.document.getElementById(input_id);
if (input) {
input.value = normalizedPaths;
console.log(`Wert in Input "${input_id}" gesetzt.`);
} else {
console.error(`Kein Input-Feld mit der ID "${input_id}" gefunden.`);
}
// Bild-Vorschau aktualisieren (sucht erstes Element mit Klasse "preview")
const img = window.opener.document.querySelector('.preview');
if (img) {
img.src = '/userdata/' + normalizedPaths.split(',')[0].trim();
img.style.display = "block";
console.log('Bild-Element mit Klasse "preview" aktualisiert.');
} else {
console.warn('Kein Bild-Element mit der Klasse "preview" gefunden.');
}
// Optional: übergebene AJAX-Funktion aufrufen
if (saveajax && typeof window.opener[saveajax] === 'function') {
console.log(`Rufe AJAX-Funktion "${saveajax}" im Parent auf.`);
window.opener[saveajax]();
} else if (saveajax) {
console.warn(`AJAX-Funktion "${saveajax}" nicht gefunden.`);
}
// Popup schließen
console.log("Popup wird geschlossen.");
window.close();
}
/**
* Fügt den Button "Auswahl speichern" in der Toolbar ein (einmalig).
*/
function addCustomButton() {
const topbar = document.querySelector('.topbar-select');
if (!topbar) return;
const buttonsContainer = topbar.querySelector('.buttons-selected');
if (!buttonsContainer) return;
if (buttonsContainer.querySelector('.my-custom-button')) return;
const button = document.createElement('button');
button.textContent = 'Auswahl speichern';
button.className = 'my-custom-button';
Object.assign(button.style, {
marginRight: '10px',
padding: '10px 20px',
backgroundColor: 'rgb(94 188 24)',
color: '#fff',
border: 'none',
borderRadius: '999px',
cursor: 'pointer'
});
button.addEventListener('click', () => {
const selectedItems = document.querySelectorAll('a.files-a[data-selected="1"]');
const selectedPaths = Array.from(selectedItems).map(el => el.dataset.path);
const pathString = selectedPaths.join(', ');
console.log('Ausgewählte Pfade:', selectedPaths);
sendDataAndClose(pathString, input_id, image_id);
});
buttonsContainer.insertBefore(button, buttonsContainer.firstChild);
}
/**
* Prüft, ob mindestens ein Element ausgewählt ist, und zeigt ggf. den Button.
*/
function checkSelectionAndAddButton() {
const anySelected = document.querySelector('a.files-a[data-selected="1"]');
if (anySelected) {
addCustomButton();
}
}
// Observer für Änderungen an `data-selected`
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (
mutation.type === 'attributes' &&
mutation.attributeName === 'data-selected'
) {
const target = mutation.target;
const isSelected = target.getAttribute('data-selected') === '1';
// Wenn single → andere Auswahl(en) aufheben
if (isSelected && mode === 'single') {
document.querySelectorAll('a.files-a[data-selected="1"]').forEach(el => {
if (el !== target) {
el.setAttribute('data-selected', '0');
}
});
}
checkSelectionAndAddButton();
break;
}
}
});
/**
* Beginnt, alle Gallery-Items auf Auswahländerungen zu beobachten.
*/
function observeFiles() {
const items = document.querySelectorAll('a.files-a');
items.forEach(item => {
observer.observe(item, {
attributes: true,
attributeFilter: ['data-selected']
});
});
}
// Initialisierung nach DOM ready
document.addEventListener('DOMContentLoaded', () => {
observeFiles();
// Falls neue Items nachgeladen werden, erneut beobachten
const listObserver = new MutationObserver(() => {
observeFiles();
});
listObserver.observe(document.body, { childList: true, subtree: true });
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,126 @@
<script>
// Redakteur (r-wiki) darf frei navigieren; Betriebsrat ist auf den Post-Ordner beschränkt
var kcUserLockedToPostFolder = <?= (!is_wiki_redakteur() && is_wiki_betriebsrat()) ? 'true' : 'false' ?>;
function buildFileGalleryUrl(target, mode, ajaxFunction, nocache) {
var base = "/module/knowledgecenter_update/Plugins/filesgallery.php?";
var postPath = (typeof window.kcPostGalleryPath === "string")
? window.kcPostGalleryPath.replace(/^\/+|\/+$/g, "")
: "";
// Redakteur: kein Lock (leerer kc_lock_path löscht den Session-Lock)
// Betriebsrat: immer auf Post-Ordner beschränken
var lockPath = kcUserLockedToPostFolder ? postPath : "";
var params = "input_id=" + encodeURIComponent(target) +
"&mode=" + encodeURIComponent(mode) +
"&saveajax=" + encodeURIComponent(ajaxFunction || "") +
"&kc_lock_path=" + encodeURIComponent(lockPath) +
"&nocache=" + nocache;
// Startverzeichnis: bei Betriebsrat immer Post-Ordner, bei Redakteur auch
var startPath = postPath;
if (startPath.length > 0) {
return base + encodeURIComponent(startPath) + "&" + params;
}
return base + params;
}
function showMessage(message, isSuccess) {
var $msg = $("#message");
// Vorherige Animationen stoppen und Opazität zurücksetzen
$msg.stop(true, true).css("opacity", 1);
// Styling je nach Erfolg oder Fehler
if (isSuccess) {
$msg.css({
"color": "green",
"position": "relative",
"background-color": "#d4edda",
"margin-bottom": "10px",
"border": "1px solid green"
});
} else {
$msg.css({
"color": "red",
"position": "relative",
"background-color": "#f8d7da",
"margin-bottom": "10px",
"border": "1px solid red"
});
}
$msg.text(message).show();
setTimeout(function () {
$msg.animate({ opacity: 0 }, 500, function () {
$msg.hide();
});
}, 3000);
}
// Popup-Öffnen für die Files Gallery
$(".openFileGallery").on("click", function () {
var target = $(this).data("target");
// Hole den Funktionsnamen aus data-ajax-function, Standardwert falls nicht definiert:
var ajaxFunction = $(this).data("ajax-function");
var nocache = new Date().getTime(); // Erzeugt einen Zeitstempel
window.open(buildFileGalleryUrl(target, "single", ajaxFunction, nocache),
"FileGallery", "width=1000,height=600");
});
$(".openFileGalleryMulti").on("click", function () {
var target = $(this).data("target");
var ajaxFunction = $(this).data("ajax-function");
var nocache = new Date().getTime(); // Zeitstempel zur Cache-Vermeidung
window.open(buildFileGalleryUrl(target, "multi", ajaxFunction, nocache),
"FileGallery", "width=1000,height=600");
});
// Funktion, die vom Files Gallery Popup im Parent-Fenster aufgerufen wird
window.setFilePath = function (path) {
if (window.fileGalleryTarget) {
$("#" + window.fileGalleryTarget).val(path);
}
};
// Alternative setFilePath-Funktion im globalen Kontext
function setFilePath(path) {
var input = document.getElementById(input_id);
if (input) {
input.value = path;
}
}
// Event-Handler für den Clear-Button
$(document).on("click", ".clearImage", function () {
// Hole den data-target Wert z.B. "image_1"
var targetId = $(this).data("target");
// Leere das versteckte Input-Feld
$("#" + targetId).val("");
// Suche das Vorschaubild innerhalb des Containers (.single_image_group)
$(this).closest('.single_image_group').find('.preview')
.attr("src", "") // Bildquelle entfernen
.css("background-color", "#f3f3f3"); // Standardhintergrund setzen
});
$(document).ready(function () {
$('.toggle').on('click', function (e) {
e.preventDefault();
// Lese das data-toggle-target Attribut des geklickten Elements aus
var target = $(this).data('toggle-target');
if (target) {
// Suche nach allen Elementen mit dem gleichen data-toggle-target,
// schließe aber das geklickte Element (und ggf. weitere Toggle-Elemente) aus.
var $targetElements = $('[data-toggle-target="' + target + '"]').not('.toggle');
if ($targetElements.length) {
$targetElements.slideToggle();
}
}
});
});
</script>

View File

@@ -0,0 +1,431 @@
<?php
/**
* Migrationsskript: Wandelt inline Base64-Bilder in
* knowledgecenter_post_versions.content in echte Dateien um.
*
* CLI-only. Idempotent. Per-Version-Transaktion mit Backup-Tabelle.
*
* Aufruf:
* php migrate_base64_to_files.php --dry-run --post-id=417 --verbose
* php migrate_base64_to_files.php --apply --post-id=417
* php migrate_base64_to_files.php --apply
*
* Optionen:
* --dry-run Standard. Nur analysieren, nichts schreiben.
* --apply Wirklich migrieren (Dateien + DB).
* --post-id=N Nur Versionen dieses Posts.
* --version-id=N Genau diese Version.
* --limit=N Maximal N Versionen verarbeiten.
* --verbose Mehr Details ins Log.
* --help Diese Hilfe.
*/
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "Dieses Skript ist CLI-only.\n");
exit(2);
}
$currDir = __DIR__;
$rootDir = dirname(dirname(dirname($currDir)));
$mysydeDir = $rootDir . '/mysyde';
require_once($rootDir . '/vendor/autoload.php');
$envDir = $rootDir . '/config';
$envFilePath = $envDir . '/.env';
if (is_dir($envDir) && file_exists($envFilePath) && is_file($envFilePath) && is_readable($envFilePath)) {
$dotenv = new \Dotenv\Dotenv($envDir);
$dotenv->load();
}
require_once($mysydeDir . '/common/common_functions.inc.php');
$connEnv = (local_environment()) ? $mysydeDir . '/dc.config.php' : $mysydeDir . '/dc-server.config.php';
require_once($connEnv);
db_connect();
// ------- Args -------
$opts = [
'dry-run' => true,
'apply' => false,
'post-id' => null,
'version-id' => null,
'limit' => null,
'verbose' => false,
];
foreach (array_slice($argv, 1) as $arg) {
if ($arg === '--help' || $arg === '-h') {
echo "Usage: php migrate_base64_to_files.php [--dry-run|--apply] [--post-id=N] [--version-id=N] [--limit=N] [--verbose]\n";
exit(0);
}
if ($arg === '--apply') { $opts['apply'] = true; $opts['dry-run'] = false; continue; }
if ($arg === '--dry-run') { $opts['dry-run'] = true; $opts['apply'] = false; continue; }
if ($arg === '--verbose') { $opts['verbose'] = true; continue; }
if (preg_match('/^--post-id=(\d+)$/', $arg, $m)) { $opts['post-id'] = (int) $m[1]; continue; }
if (preg_match('/^--version-id=(\d+)$/', $arg, $m)) { $opts['version-id'] = (int) $m[1]; continue; }
if (preg_match('/^--limit=(\d+)$/', $arg, $m)) { $opts['limit'] = (int) $m[1]; continue; }
fwrite(STDERR, "Unbekannte Option: $arg\n");
exit(2);
}
$dryRun = !$opts['apply'];
$verbose = $opts['verbose'];
$runId = bin2hex(random_bytes(8));
// ------- Logging -------
$logDir = $rootDir . '/userdata/knowledgecenter_update/migrations';
if (!is_dir($logDir) && !@mkdir($logDir, 0775, true)) {
fwrite(STDERR, "Log-Verzeichnis nicht anlegbar: $logDir\n");
exit(3);
}
$logFile = $logDir . '/base64_to_files_' . gmdate('Ymd_His') . '_' . $runId . '.log';
$logFp = fopen($logFile, 'w');
if (!$logFp) {
fwrite(STDERR, "Log-Datei nicht öffenbar: $logFile\n");
exit(3);
}
function logmsg($msg, $level = 'INFO') {
global $logFp, $verbose;
$line = '[' . gmdate('Y-m-d H:i:s') . " UTC] [$level] $msg\n";
fwrite($logFp, $line);
if ($verbose || in_array($level, ['ERROR', 'FAIL', 'OK', 'WARN', 'HEAD'], true)) {
fwrite(STDOUT, $line);
}
}
logmsg("Migration-Run gestartet. run_id=$runId mode=" . ($dryRun ? 'DRY-RUN' : 'APPLY')
. " post-id=" . ($opts['post-id'] ?? 'ALL')
. " version-id=" . ($opts['version-id'] ?? 'ALL')
. " limit=" . ($opts['limit'] ?? 'unbeschränkt'), 'HEAD');
// ------- Backup-Tabelle anlegen -------
$createBackup = "CREATE TABLE IF NOT EXISTS knowledgecenter_post_versions_migration_backup (
id INT AUTO_INCREMENT PRIMARY KEY,
version_id INT NOT NULL,
post_id INT NOT NULL,
language_id INT NOT NULL,
version_number INT NOT NULL,
original_content LONGTEXT NOT NULL,
original_length INT NOT NULL,
images_extracted INT NOT NULL,
migrated_at DATETIME NOT NULL,
script_run_id VARCHAR(32) NOT NULL,
UNIQUE KEY uniq_version_run (version_id, script_run_id),
KEY idx_run (script_run_id),
KEY idx_post (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
if (!mysqli_query($GLOBALS['mysql_con'], $createBackup)) {
logmsg('Backup-Tabelle konnte nicht angelegt werden: ' . mysqli_error($GLOBALS['mysql_con']), 'ERROR');
exit(4);
}
// ------- Kandidaten finden -------
$where = ["content LIKE '%data:image/%;base64,%'"];
if ($opts['post-id'] !== null) $where[] = "post_id = " . (int) $opts['post-id'];
if ($opts['version-id'] !== null) $where[] = "id = " . (int) $opts['version-id'];
$sql = "SELECT id, post_id, language_id, version_number, content
FROM knowledgecenter_post_versions
WHERE " . implode(' AND ', $where) . "
ORDER BY post_id, language_id, version_number";
if ($opts['limit'] !== null) $sql .= " LIMIT " . (int) $opts['limit'];
$res = mysqli_query($GLOBALS['mysql_con'], $sql);
if (!$res) {
logmsg('Query fehlgeschlagen: ' . mysqli_error($GLOBALS['mysql_con']), 'ERROR');
exit(5);
}
$candidateCount = mysqli_num_rows($res);
logmsg("Gefundene Kandidaten: $candidateCount", 'HEAD');
// ------- Helper -------
function mime_to_ext($mime) {
static $map = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
return isset($map[$mime]) ? $map[$mime] : null;
}
function ensure_post_inline_dir($rootDir, $postId, $dryRun) {
$hash = substr(sha1('kc_post_' . (int) $postId), 0, 24);
$relBase = 'knowledgecenter_update/posts/' . $hash;
$absBase = $rootDir . '/userdata/' . $relBase;
$absInline = $absBase . '/inline';
if ($dryRun) {
return ['hash' => $hash, 'relBase' => $relBase, 'absBase' => $absBase, 'absInline' => $absInline, 'created' => false];
}
if (!is_dir($absBase) && !@mkdir($absBase, 0775, true)) {
throw new RuntimeException("Post-Ordner nicht anlegbar: $absBase");
}
$sharedLink = $absBase . '/shared';
if (!is_link($sharedLink) && !file_exists($sharedLink)) {
@symlink('../../shared', $sharedLink);
}
if (!is_dir($absInline) && !@mkdir($absInline, 0775, true)) {
throw new RuntimeException("Inline-Ordner nicht anlegbar: $absInline");
}
return ['hash' => $hash, 'relBase' => $relBase, 'absBase' => $absBase, 'absInline' => $absInline, 'created' => true];
}
function process_version($row, $rootDir, $dryRun, $runId, $verbose) {
$versionId = (int) $row['id'];
$postId = (int) $row['post_id'];
$langId = (int) $row['language_id'];
$verNum = (int) $row['version_number'];
$content = (string) $row['content'];
$origLen = strlen($content);
$tag = "version_id=$versionId post_id=$postId lang=$langId v=$verNum";
// DOM laden
$wrapped = '<?xml encoding="UTF-8"?><div id="kc_root">' . $content . '</div>';
$prev = libxml_use_internal_errors(true);
$dom = new DOMDocument();
$loaded = $dom->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING);
libxml_clear_errors();
libxml_use_internal_errors($prev);
if (!$loaded) {
logmsg("[FAIL] $tag DOM load fehlgeschlagen", 'FAIL');
return ['ok' => false, 'rewrote' => 0];
}
$root = $dom->getElementById('kc_root');
if (!$root) {
logmsg("[FAIL] $tag wrapper nicht gefunden", 'FAIL');
return ['ok' => false, 'rewrote' => 0];
}
$imgs = $dom->getElementsByTagName('img');
$imgList = [];
foreach ($imgs as $img) { $imgList[] = $img; }
$rewrote = 0;
$skippedNoBase64 = 0;
$skippedBadDecode = 0;
$skippedBadMime = 0;
$skippedTooBig = 0;
$skippedSrcset = 0;
$totalBytesIn = 0;
foreach ($imgList as $img) {
if ($img->hasAttribute('srcset')) {
$skippedSrcset++;
logmsg("[WARN] $tag <img srcset=...> belassen", 'WARN');
continue;
}
$src = $img->getAttribute('src');
if ($src === '' || strncmp($src, 'data:', 5) !== 0) continue;
if (!preg_match('#^data:(image/[a-z0-9.+-]+)?(;charset=[^;,]+)?(;base64)?,(.*)$#is', $src, $m)) {
$skippedNoBase64++;
logmsg("[WARN] $tag data-URI nicht parsbar, belassen", 'WARN');
continue;
}
$declaredMime = strtolower($m[1] ?? '');
$isBase64 = !empty($m[3]);
$payload = $m[4];
if (!$isBase64) {
$skippedNoBase64++;
logmsg("[WARN] $tag data-URI ohne ;base64, belassen", 'WARN');
continue;
}
$decoded = base64_decode($payload, true);
if ($decoded === false || $decoded === '') {
$skippedBadDecode++;
logmsg("[WARN] $tag base64 nicht dekodierbar, belassen", 'WARN');
continue;
}
$size = strlen($decoded);
$totalBytesIn += $size;
if ($size > 5 * 1024 * 1024) {
$skippedTooBig++;
logmsg("[WARN] $tag Bild > 5MB ($size bytes), belassen", 'WARN');
continue;
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$detectedMime = $finfo ? finfo_buffer($finfo, $decoded) : false;
if ($finfo) finfo_close($finfo);
$ext = mime_to_ext($detectedMime);
if (!$ext) {
$skippedBadMime++;
logmsg("[WARN] $tag Mime '$detectedMime' nicht erlaubt (declared='$declaredMime'), belassen", 'WARN');
continue;
}
$dir = ensure_post_inline_dir($rootDir, $postId, $dryRun);
$sha = hash('sha256', $decoded);
$filename = $sha . '.' . $ext;
$finalPath = $dir['absInline'] . '/' . $filename;
$publicUrl = '/userdata/' . $dir['relBase'] . '/inline/' . $filename;
if ($dryRun) {
if ($verbose) logmsg("[DRY ] $tag would write $finalPath ($size bytes) → $publicUrl", 'INFO');
} else {
$writeNeeded = true;
if (file_exists($finalPath)) {
$existingSize = filesize($finalPath);
if ($existingSize === $size) {
$writeNeeded = false;
if ($verbose) logmsg("[INFO] $tag dedup hit $finalPath", 'INFO');
} else {
logmsg("[WARN] $tag SHA-Kollision?! $finalPath bestehend=$existingSize bytes, neu=$size bytes überspringe", 'WARN');
$writeNeeded = false;
}
}
if ($writeNeeded) {
$tmp = $dir['absInline'] . '/.tmp_' . bin2hex(random_bytes(6));
if (file_put_contents($tmp, $decoded) === false) {
logmsg("[FAIL] $tag konnte tmp-Datei nicht schreiben", 'FAIL');
return ['ok' => false, 'rewrote' => $rewrote];
}
@chmod($tmp, 0664);
if (!rename($tmp, $finalPath)) {
@unlink($tmp);
logmsg("[FAIL] $tag rename failed", 'FAIL');
return ['ok' => false, 'rewrote' => $rewrote];
}
}
}
$img->setAttribute('src', $publicUrl);
$rewrote++;
}
// Serialisieren
$newContent = '';
foreach ($root->childNodes as $child) {
$newContent .= $dom->saveHTML($child);
}
$newLen = strlen($newContent);
$report = sprintf(
"%s images_rewritten=%d skipped(no_b64=%d bad_decode=%d bad_mime=%d too_big=%d srcset=%d) from_kb=%d to_kb=%d",
$tag, $rewrote, $skippedNoBase64, $skippedBadDecode, $skippedBadMime, $skippedTooBig, $skippedSrcset,
(int) round($origLen / 1024), (int) round($newLen / 1024)
);
return [
'ok' => true,
'rewrote' => $rewrote,
'newContent'=> $newContent,
'origLen' => $origLen,
'newLen' => $newLen,
'report' => $report,
];
}
// ------- Verarbeitung -------
$totals = ['ok' => 0, 'rewritten_images' => 0, 'fail' => 0, 'no_change' => 0, 'bytes_saved' => 0];
while ($row = mysqli_fetch_assoc($res)) {
$versionId = (int) $row['id'];
$postId = (int) $row['post_id'];
if (!$dryRun) mysqli_query($GLOBALS['mysql_con'], 'START TRANSACTION');
try {
$result = process_version($row, $rootDir, $dryRun, $runId, $verbose);
} catch (Throwable $e) {
if (!$dryRun) mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
logmsg("[FAIL] version_id=$versionId Exception: " . $e->getMessage(), 'FAIL');
$totals['fail']++;
continue;
}
if (!$result['ok']) {
if (!$dryRun) mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
$totals['fail']++;
continue;
}
if ($result['rewrote'] === 0) {
if (!$dryRun) mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
logmsg("[SKIP] " . $result['report'] . " (kein <img> umgeschrieben)", 'WARN');
$totals['no_change']++;
continue;
}
if ($dryRun) {
logmsg("[DRY ] " . $result['report'], 'OK');
$totals['ok']++;
$totals['rewritten_images'] += $result['rewrote'];
$totals['bytes_saved'] += max(0, $result['origLen'] - $result['newLen']);
continue;
}
// Backup + UPDATE
$stmt = mysqli_prepare(
$GLOBALS['mysql_con'],
"INSERT INTO knowledgecenter_post_versions_migration_backup
(version_id, post_id, language_id, version_number, original_content, original_length, images_extracted, migrated_at, script_run_id)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), ?)"
);
if (!$stmt) {
mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
logmsg("[FAIL] version_id=$versionId backup-prepare: " . mysqli_error($GLOBALS['mysql_con']), 'FAIL');
$totals['fail']++;
continue;
}
$verId = (int) $row['id'];
$pId = (int) $row['post_id'];
$lId = (int) $row['language_id'];
$vNum = (int) $row['version_number'];
$origCt = (string) $row['content'];
$origLen = $result['origLen'];
$rewrote = $result['rewrote'];
mysqli_stmt_bind_param($stmt, 'iiiisiis', $verId, $pId, $lId, $vNum, $origCt, $origLen, $rewrote, $runId);
if (!mysqli_stmt_execute($stmt)) {
mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
logmsg("[FAIL] version_id=$versionId backup-insert: " . mysqli_stmt_error($stmt), 'FAIL');
mysqli_stmt_close($stmt);
$totals['fail']++;
continue;
}
mysqli_stmt_close($stmt);
$stmt2 = mysqli_prepare(
$GLOBALS['mysql_con'],
"UPDATE knowledgecenter_post_versions SET content = ? WHERE id = ?"
);
if (!$stmt2) {
mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
logmsg("[FAIL] version_id=$versionId update-prepare: " . mysqli_error($GLOBALS['mysql_con']), 'FAIL');
$totals['fail']++;
continue;
}
$newCt = $result['newContent'];
mysqli_stmt_bind_param($stmt2, 'si', $newCt, $verId);
if (!mysqli_stmt_execute($stmt2)) {
mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
logmsg("[FAIL] version_id=$versionId update-exec: " . mysqli_stmt_error($stmt2), 'FAIL');
mysqli_stmt_close($stmt2);
$totals['fail']++;
continue;
}
mysqli_stmt_close($stmt2);
if (!mysqli_query($GLOBALS['mysql_con'], 'COMMIT')) {
logmsg("[FAIL] version_id=$versionId COMMIT-Fehler: " . mysqli_error($GLOBALS['mysql_con']), 'FAIL');
$totals['fail']++;
continue;
}
logmsg("[OK ] " . $result['report'], 'OK');
$totals['ok']++;
$totals['rewritten_images'] += $result['rewrote'];
$totals['bytes_saved'] += max(0, $result['origLen'] - $result['newLen']);
}
logmsg(sprintf(
"Fertig. ok=%d fail=%d no_change=%d rewritten_images=%d bytes_saved=%d (%.2f MB) mode=%s",
$totals['ok'], $totals['fail'], $totals['no_change'], $totals['rewritten_images'],
$totals['bytes_saved'], $totals['bytes_saved'] / 1024 / 1024,
$dryRun ? 'DRY-RUN' : 'APPLY'
), 'HEAD');
logmsg("Log-Datei: $logFile", 'HEAD');
fclose($logFp);
exit($totals['fail'] > 0 ? 1 : 0);

View File

@@ -0,0 +1,473 @@
<?php
/**
* Migration: Alte Wiki-Artikel (main_collection) → Neue Wiki-Struktur (knowledgecenter_posts)
*
* CLI-only. Idempotent (über legacy_collection_id). Pro Artikel transaktional.
*
* Aufruf:
* php migrate_old_wiki.php --dry-run --collection-id=1491
* php migrate_old_wiki.php --apply --collection-id=1491 --api-key=KEY
* php migrate_old_wiki.php --apply --limit=5 --api-key=KEY
* php migrate_old_wiki.php --apply (alle Artikel)
*
* Optionen:
* --dry-run Standard. Nur analysieren, nichts schreiben.
* --apply Wirklich migrieren (DB + Dateien).
* --collection-id=N Nur diesen Artikel migrieren.
* --limit=N Maximal N Artikel migrieren (für Tests).
* --api-key=KEY Gemini API Key (oder via GEMINI_API_KEY env).
* --skip-summaries Keine KI-Zusammenfassung generieren.
* --verbose Mehr Details ausgeben.
* --help Diese Hilfe.
*/
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "Dieses Skript ist CLI-only.\n");
exit(2);
}
$currDir = __DIR__;
$rootDir = dirname(dirname(dirname($currDir)));
$mysydeDir = $rootDir . '/mysyde';
require_once($rootDir . '/vendor/autoload.php');
$envDir = $rootDir . '/config';
$envFilePath = $envDir . '/.env';
if (is_dir($envDir) && file_exists($envFilePath) && is_readable($envFilePath)) {
$dotenv = new \Dotenv\Dotenv($envDir);
$dotenv->load();
}
require_once($mysydeDir . '/common/common_functions.inc.php');
$connEnv = (local_environment()) ? $mysydeDir . '/dc.config.php' : $mysydeDir . '/dc-server.config.php';
require_once($connEnv);
db_connect();
// ------- Args -------
$opts = [
'dry-run' => true,
'apply' => false,
'collection-id' => null,
'limit' => null,
'api-key' => getenv('GEMINI_API_KEY') ?: null,
'skip-summaries' => false,
'summaries-only' => false,
'verbose' => false,
];
foreach (array_slice($argv, 1) as $arg) {
if ($arg === '--help' || $arg === '-h') {
echo "Usage: php migrate_old_wiki.php [--dry-run|--apply] [--collection-id=N] [--limit=N] [--api-key=KEY] [--skip-summaries] [--verbose]\n";
exit(0);
}
if ($arg === '--apply') { $opts['apply'] = true; $opts['dry-run'] = false; continue; }
if ($arg === '--dry-run') { $opts['dry-run'] = true; $opts['apply'] = false; continue; }
if ($arg === '--skip-summaries') { $opts['skip-summaries'] = true; continue; }
if ($arg === '--summaries-only') { $opts['summaries-only'] = true; continue; }
if ($arg === '--verbose') { $opts['verbose'] = true; continue; }
if (preg_match('/^--collection-id=(\d+)$/', $arg, $m)) { $opts['collection-id'] = (int)$m[1]; continue; }
if (preg_match('/^--limit=(\d+)$/', $arg, $m)) { $opts['limit'] = (int)$m[1]; continue; }
if (preg_match('/^--api-key=(.+)$/', $arg, $m)) { $opts['api-key'] = $m[1]; continue; }
fwrite(STDERR, "Unbekannte Option: $arg\n");
exit(2);
}
$dryRun = !$opts['apply'];
$verbose = $opts['verbose'];
$apiKey = $opts['api-key'];
$skipSummary = $opts['skip-summaries'];
$userdataDir = $rootDir . '/userdata';
// ------- Log-Datei -------
$logDir = $userdataDir . '/knowledgecenter_update/migrations';
if (!is_dir($logDir)) @mkdir($logDir, 0775, true);
$logFile = $logDir . '/wiki_migration_' . gmdate('Ymd_His') . '.log';
$logFp = fopen($logFile, 'w');
if (!$logFp) { fwrite(STDERR, "Log-Datei nicht anlegbar: $logFile\n"); exit(3); }
fwrite(STDOUT, "Log: $logFile\n");
// ------- Logging -------
function logmsg(string $msg, string $level = 'INFO'): void {
global $verbose, $logFp;
$line = '[' . gmdate('Y-m-d H:i:s') . " UTC] [$level] $msg\n";
fwrite($logFp, $line);
if ($verbose || in_array($level, ['ERROR', 'FAIL', 'OK', 'WARN', 'HEAD', 'SKIP', 'PROMPT'], true)) {
fwrite(STDOUT, $line);
}
}
// ------- Phase 0: Schema-Vorbereitung -------
logmsg("Prüfe Schema ...", 'HEAD');
mysqli_query($GLOBALS['mysql_con'],
"ALTER TABLE knowledgecenter_posts ADD COLUMN IF NOT EXISTS legacy_collection_id INT NULL");
mysqli_query($GLOBALS['mysql_con'],
"CREATE UNIQUE INDEX IF NOT EXISTS idx_legacy_collection ON knowledgecenter_posts(legacy_collection_id)");
// ------- Hilfsfunktionen -------
/**
* Returns ['teaser' => string, 'content' => string]
* setup_content_id=55 (kc_teaser, data-field) → teaser
* setup_content_id=58 (kc_article, textcontent_header) → content
*/
function assemble_content(int $cid): array {
$sql = "SELECT mcl.main_collection_setup_content_id, mcl.main_sitepart_id,
mcl.main_sitepart_header_id, mcl.data
FROM main_collection_link mcl
WHERE mcl.main_collection_id = $cid
ORDER BY mcl.id";
$res = mysqli_query($GLOBALS['mysql_con'], $sql);
$teaser = '';
$content = '';
while ($row = mysqli_fetch_assoc($res)) {
$scid = (int)$row['main_collection_setup_content_id'];
if ($scid === 55) {
// Teaser: plain text im data-Feld, keine HTML-Tags
$text = trim($row['data'] ?? '');
if ($text !== '') {
$teaser .= ($teaser !== '' ? ' ' : '') . $text;
}
} elseif ($row['main_sitepart_id'] == 1 && $row['main_sitepart_header_id'] > 0) {
// Artikeltext: HTML aus textcontent_header (setup_content_id=58 oder ähnlich)
$hid = (int)$row['main_sitepart_header_id'];
$hres = mysqli_query($GLOBALS['mysql_con'],
"SELECT content FROM textcontent_header WHERE id = $hid LIMIT 1");
if ($hrow = mysqli_fetch_assoc($hres)) {
$content .= $hrow['content'];
}
}
}
return ['teaser' => $teaser, 'content' => $content];
}
function get_article_files(int $cid): array {
// sitepart_id 5, 6, 10 sind filegallery-Typen; sitepart_id=1 ist textcontent_header (nicht joinen!)
$sql = "SELECT DISTINCT fl.filename, fl.extension, fl.description
FROM main_collection_link mcl
JOIN filegallery_line fl ON fl.header_id = mcl.main_sitepart_header_id
WHERE mcl.main_collection_id = $cid
AND mcl.main_sitepart_id IN (5, 6, 10)
AND mcl.main_sitepart_header_id > 0
AND fl.filename != ''
ORDER BY fl.sorting";
$res = mysqli_query($GLOBALS['mysql_con'], $sql);
$files = [];
while ($row = mysqli_fetch_assoc($res)) {
$files[] = $row;
}
return $files;
}
function extract_pdf_text(string $filePath): string {
if (!file_exists($filePath) || filesize($filePath) === 0) return '';
// pdftotext ist robuster bei gesicherten PDFs (nur Kopier-Schutz, kein Passwort)
$pdftotext = trim(shell_exec('which pdftotext 2>/dev/null') ?? '');
if ($pdftotext !== '') {
$escaped = escapeshellarg($filePath);
$out = shell_exec("$pdftotext -nopgbrk $escaped - 2>/dev/null");
if ($out !== null && strlen(trim($out)) > 10) {
return preg_replace('/\s+/', ' ', trim($out));
}
}
// Fallback: pdfparser
try {
$parser = new \Smalot\PdfParser\Parser();
$pdf = $parser->parseFile($filePath);
$text = trim($pdf->getText());
return preg_replace('/\s+/', ' ', $text);
} catch (\Throwable $e) {
return '';
}
}
function generate_summary(string $htmlContent, array $files, string $userdataDir, string $apiKey, string $title, string $teaser = ''): ?string {
$parts = [];
// Teaser bevorzugen: bereits plain text, sehr kurz → spart Input-Tokens
if (strlen($teaser) > 30) {
$parts[] = $teaser;
}
// Fließtext: HTML entfernen, auf 1500 Zeichen cappen
$plainText = html_entity_decode(strip_tags($htmlContent), ENT_HTML5 | ENT_QUOTES, 'UTF-8');
$plainText = trim(preg_replace('/\s+/', ' ', $plainText));
if (strlen($plainText) > 30) {
$parts[] = mb_substr($plainText, 0, 1500);
}
// PDFs nur wenn sonst kein Inhalt vorhanden, auf 800 Zeichen begrenzen
if (empty($parts)) {
foreach ($files as $file) {
if (strtolower($file['extension']) !== 'pdf') continue;
$pdfText = extract_pdf_text($userdataDir . '/' . $file['filename']);
if (strlen($pdfText) > 50) {
$label = $file['description'] ?: basename($file['filename']);
$parts[] = 'Anhang "' . $label . '": ' . mb_substr($pdfText, 0, 800);
break; // ein PDF reicht
}
}
}
if (empty($parts)) {
return null;
}
$prompt = "Fasse in 2-3 deutschen Sätzen zusammen. Nur Zusammenfassung.\nTitel: $title\n\n"
. implode("\n\n", $parts);
logmsg(" PROMPT (" . strlen($prompt) . " Zeichen)", 'PROMPT');
$payload = json_encode([
'contents' => [['parts' => [['text' => $prompt]]]],
'generationConfig' => [
'maxOutputTokens' => 150,
'temperature' => 0.2,
'thinkingConfig' => ['thinkingBudget' => 0],
],
]);
$url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=' . urlencode($apiKey);
$resp = '';
$httpCode = 0;
$attempts = [[0, null], [5, 503], [15, 503], [30, 429], [60, 429]];
foreach ($attempts as [$wait, $label]) {
if ($wait > 0) {
logmsg(" Gemini $httpCode warte {$wait}s (retry) ...", 'WARN');
sleep($wait);
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => $payload,
]);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 503 && $httpCode !== 429) break;
}
if ($httpCode !== 200) {
logmsg("Gemini API Fehler HTTP $httpCode: " . substr($resp, 0, 200), 'WARN');
return null;
}
$data = json_decode($resp, true);
$summary = trim($data['candidates'][0]['content']['parts'][0]['text'] ?? '');
if (strlen($summary) < 30) {
logmsg(" Gemini Roh-Antwort: " . substr($resp, 0, 500), 'WARN');
}
return $summary;
}
// ------- Permissions aus main_collection auf Post übertragen -------
function copy_permissions_to_post(int $postId, int $collectionId): void {
$maps = [
['src' => 'main_collection_mandant_link', 'src_col' => 'main_mandant_id', 'dst' => 'knowledgecenter_post_mandant', 'dst_col' => 'mandant_id'],
['src' => 'main_collection_department_link', 'src_col' => 'main_department_id', 'dst' => 'knowledgecenter_post_department', 'dst_col' => 'department_id'],
['src' => 'main_collection_role_link', 'src_col' => 'main_role_id', 'dst' => 'knowledgecenter_post_role', 'dst_col' => 'role_id'],
['src' => 'main_collection_einricht_link', 'src_col' => 'main_einricht_id', 'dst' => 'knowledgecenter_post_einricht', 'dst_col' => 'einricht_id'],
['src' => 'main_collection_fachbereich_link', 'src_col' => 'main_fachbereich_id', 'dst' => 'knowledgecenter_post_fachbereich', 'dst_col' => 'fachbereich_id'],
];
foreach ($maps as $m) {
$sql = "INSERT IGNORE INTO {$m['dst']} (post_id, {$m['dst_col']})
SELECT $postId, {$m['src_col']} FROM {$m['src']} WHERE main_collection_id = $collectionId";
mysqli_query($GLOBALS['mysql_con'], $sql);
}
}
// ------- Artikel laden -------
$whereClause = "mc.main_collection_setup_id = 3 AND mc.is_archived = 0 AND mc.active = 1 AND mc.description != ''";
if ($opts['collection-id'] !== null) {
$whereClause .= " AND mc.id = " . (int)$opts['collection-id'];
}
// ------- Summaries-only Modus: fehlende Summaries für bereits migrierte Posts nachfüllen -------
if ($opts['summaries-only']) {
if (!$apiKey) { fwrite(STDERR, "--summaries-only benötigt --api-key\n"); exit(2); }
$sumSql = "SELECT p.id as post_id, p.legacy_collection_id, pv.id as version_id, pv.title,
pv.teaser, pv.content
FROM knowledgecenter_posts p
JOIN knowledgecenter_post_versions pv ON pv.post_id = p.id AND pv.version_number = 1
WHERE (pv.summary IS NULL OR pv.summary = '')
AND p.legacy_collection_id IS NOT NULL";
if ($opts['collection-id'] !== null) {
$sumSql .= " AND p.legacy_collection_id = " . (int)$opts['collection-id'];
}
if ($opts['limit'] !== null) {
$sumSql .= " LIMIT " . (int)$opts['limit'];
}
$sumResult = mysqli_query($GLOBALS['mysql_con'], $sumSql);
$sumTotal = mysqli_num_rows($sumResult);
logmsg("Summaries-only: $sumTotal Posts ohne Summary", 'HEAD');
$sumStats = ['ok' => 0, 'fail' => 0];
while ($sumRow = mysqli_fetch_assoc($sumResult)) {
$postId = (int)$sumRow['post_id'];
$collId = (int)$sumRow['legacy_collection_id'];
$versionId = (int)$sumRow['version_id'];
$title = $sumRow['title'];
$files = get_article_files($collId);
$teaser = $sumRow['teaser'] ?? '';
$summary = generate_summary($sumRow['content'], $files, $userdataDir, $apiKey, $title, $teaser) ?? '';
if ($summary === '') {
logmsg(" [FAIL] Post $postId \"$title\": leere Summary", 'WARN');
$sumStats['fail']++;
} else {
$sumEsc = mysqli_real_escape_string($GLOBALS['mysql_con'], $summary);
mysqli_query($GLOBALS['mysql_con'],
"UPDATE knowledgecenter_post_versions SET summary = '$sumEsc' WHERE id = $versionId");
logmsg("[OK ] Post $postId \"$title\": " . mb_substr($summary, 0, 80), 'OK');
$sumStats['ok']++;
}
sleep(4); // 15 RPM Free-Tier: mind. 4 Sek. zwischen Requests
}
logmsg(sprintf("Summaries fertig. ok=%d fail=%d", $sumStats['ok'], $sumStats['fail']), 'HEAD');
logmsg("Log-Datei: $logFile", 'HEAD');
fclose($logFp);
exit(0);
}
$sql = "SELECT mc.id, mc.description, mc.creation_date, mc.modified_date,
mc.validity_from, mc.validity_to
FROM main_collection mc
WHERE $whereClause
ORDER BY mc.id";
if ($opts['limit'] !== null) {
$sql .= " LIMIT " . (int)$opts['limit'];
}
$result = mysqli_query($GLOBALS['mysql_con'], $sql);
$total = mysqli_num_rows($result);
logmsg("Modus=" . ($dryRun ? 'DRY-RUN' : 'APPLY') . " Artikel=$total Limit=" .
($opts['limit'] ?? 'alle') . " API=" . ($apiKey && !$skipSummary ? 'ja' : 'nein'), 'HEAD');
$stats = ['ok' => 0, 'skipped' => 0, 'fail' => 0];
while ($row = mysqli_fetch_assoc($result)) {
$cid = (int)$row['id'];
$title = $row['description'];
// Idempotenz-Check
$already = mysqli_query($GLOBALS['mysql_con'],
"SELECT id FROM knowledgecenter_posts WHERE legacy_collection_id = $cid LIMIT 1");
if ($already && mysqli_num_rows($already) > 0) {
logmsg("Artikel $cid \"$title\" bereits migriert, überspringe.", 'SKIP');
$stats['skipped']++;
continue;
}
$parts = assemble_content($cid);
$teaserHtml = $parts['teaser'];
$htmlContent = $parts['content'];
$files = get_article_files($cid);
$htmlLen = strlen($htmlContent);
$catRes = mysqli_query($GLOBALS['mysql_con'],
"SELECT knowledgecenter_category_id FROM knowledgecenter_collection_link WHERE main_collection_id = $cid");
$catIds = [];
while ($cr = mysqli_fetch_assoc($catRes)) {
$catIds[] = (int)$cr['knowledgecenter_category_id'];
}
logmsg("Artikel $cid \"$title\" | Teaser=" . strlen($teaserHtml) . "B | HTML=" . $htmlLen . "B | Dateien=" . count($files) .
" | Kategorien=[" . implode(',', $catIds) . "]");
if ($dryRun) {
$stats['ok']++;
continue;
}
// Summary generieren (vor der Transaktion, API-Call außerhalb)
$summary = '';
if (!$skipSummary && $apiKey) {
$fullContent = $teaserHtml . $htmlContent;
$generated = generate_summary($fullContent, $files, $userdataDir, $apiKey, $title);
$summary = $generated ?? '';
logmsg(" Summary: " . (strlen($summary) > 0 ? '"' . mb_substr($summary, 0, 100) . '"' : '(leer)'));
sleep(4); // 15 RPM Free-Tier: mind. 4 Sek. zwischen Requests
}
// HTML-Entities dekodieren für saubere UTF-8 Speicherung
$cleanHtml = html_entity_decode($htmlContent, ENT_HTML5 | ENT_QUOTES, 'UTF-8');
mysqli_query($GLOBALS['mysql_con'], 'START TRANSACTION');
try {
$titleEsc = mysqli_real_escape_string($GLOBALS['mysql_con'], $title);
$summaryEsc = mysqli_real_escape_string($GLOBALS['mysql_con'], $summary);
$teaserEsc = mysqli_real_escape_string($GLOBALS['mysql_con'], $teaserHtml);
$contentEsc = mysqli_real_escape_string($GLOBALS['mysql_con'], $cleanHtml);
$createdAt = date('Y-m-d H:i:s', (int)$row['creation_date']);
$modifiedAt = date('Y-m-d H:i:s', (int)$row['modified_date']);
$validFrom = $row['validity_from'] ? "'" . $row['validity_from'] . "'" : 'NULL';
$validUntil = $row['validity_to'] ? "'" . $row['validity_to'] . "'" : 'NULL';
// knowledgecenter_posts
$sqlPost = "INSERT INTO knowledgecenter_posts (legacy_collection_id, created_at, modified_at)
VALUES ($cid, '$createdAt', '$modifiedAt')";
if (!mysqli_query($GLOBALS['mysql_con'], $sqlPost)) {
throw new RuntimeException("Post-Insert: " . mysqli_error($GLOBALS['mysql_con']));
}
$postId = (int)mysqli_insert_id($GLOBALS['mysql_con']);
// knowledgecenter_post_versions
$sqlVer = "INSERT INTO knowledgecenter_post_versions
(post_id, version_number, language_id, title, teaser, content, summary, state, modified_at, valid_from, valid_until)
VALUES
($postId, 1, 1, '$titleEsc', '$teaserEsc', '$contentEsc', '$summaryEsc', 1, '$modifiedAt', $validFrom, $validUntil)";
if (!mysqli_query($GLOBALS['mysql_con'], $sqlVer)) {
throw new RuntimeException("Version-Insert: " . mysqli_error($GLOBALS['mysql_con']));
}
// Kategorie-Links
foreach ($catIds as $catId) {
mysqli_query($GLOBALS['mysql_con'],
"INSERT IGNORE INTO knowledgecenter_category_post (category_id, post_id) VALUES ($catId, $postId)");
}
// Permissions aus main_collection auf Post übertragen
copy_permissions_to_post($postId, $cid);
// Dateiverknüpfungen: Original-Pfad beibehalten, keine Kopie
foreach ($files as $file) {
$srcPath = $userdataDir . '/' . $file['filename'];
if (!file_exists($srcPath)) {
logmsg(" Quelldatei nicht gefunden: " . $file['filename'], 'WARN');
continue;
}
$relPath = '/userdata/' . $file['filename'];
$descEsc = mysqli_real_escape_string($GLOBALS['mysql_con'], $file['description'] ?: basename($file['filename']));
$pathEsc = mysqli_real_escape_string($GLOBALS['mysql_con'], $relPath);
mysqli_query($GLOBALS['mysql_con'],
"INSERT INTO knowledgecenter_post_files (post_id, description, path)
VALUES ($postId, '$descEsc', '$pathEsc')");
logmsg(" Datei verknüpft: " . $file['filename']);
}
mysqli_query($GLOBALS['mysql_con'], 'COMMIT');
logmsg("[OK ] Artikel $cid Post $postId \"$title\"", 'OK');
$stats['ok']++;
} catch (\Throwable $e) {
mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
logmsg("[FAIL] Artikel $cid \"$title\": " . $e->getMessage(), 'FAIL');
$stats['fail']++;
}
}
logmsg(sprintf("Fertig. ok=%d skipped=%d fail=%d modus=%s",
$stats['ok'], $stats['skipped'], $stats['fail'],
$dryRun ? 'DRY-RUN' : 'APPLY'), 'HEAD');
logmsg("Log-Datei: $logFile", 'HEAD');
fclose($logFp);

View File

@@ -0,0 +1 @@
<iframe class="fullscreen" src="/intranet/de/wiki/?action=Categories&embedded=1" frameborder="0"></iframe>

View File

@@ -0,0 +1,901 @@
<?php
// ------------------------------
// Cardform mit zentralem Übersetzungsspeicher und Unterkategorien
// ------------------------------
// ini_set('display_errors', 1);
// ini_set('display_startup_errors', 1);
// error_reporting(E_ALL);
// Initialisierung der Variablen
if (isset($_GET['detail']) && $_GET['detail'] === 'new') {
require_permission('r-wiki');
$categoryId = 0; // Kennzeichne, dass es sich um einen neuen Eintrag handelt
// Leere Standardwerte definieren:
$category = [
'id' => 0,
'modified_at' => '',
'modified_by' => ''
];
// Für alle Sprachen leere Übersetzungen:
$translations = [];
foreach ($languages as $lang) {
$translations[$lang['code']] = ['title' => '', 'image' => ''];
}
} else {
$categoryId = isset($_GET['detail']) ? (int) $_GET['detail'] : 0;
$query = "SELECT c.* FROM knowledgecenter_categories_update AS c WHERE c.id = $categoryId LIMIT 1";
$result = mysqli_query($GLOBALS['mysql_con'], $query);
$category = mysqli_fetch_assoc($result);
$state = isset($category['state']) ? (int) $category['state'] : 0;
$translations = [];
foreach ($languages as $lang) {
$langId = $lang['id'];
$transQuery = "SELECT title, image, is_navigation_item
FROM knowledgecenter_category_translations
WHERE category_id = $categoryId AND language_id = $langId LIMIT 1";
$transResult = mysqli_query($GLOBALS['mysql_con'], $transQuery);
$translations[$lang['code']] = mysqli_fetch_assoc($transResult) ?: [
'title' => '',
'image' => '',
'is_navigation_item' => 0
];
}
}
// Mandanten abrufen
$mandants = [];
$resultMandants = mysqli_query($GLOBALS['mysql_con'], "SELECT id, description FROM main_mandant");
while ($row = mysqli_fetch_assoc($resultMandants)) {
$mandants[] = $row;
}
// Departments abrufen
$departments = [];
$resultDepartments = mysqli_query($GLOBALS['mysql_con'], "SELECT id, description FROM main_department");
while ($row = mysqli_fetch_assoc($resultDepartments)) {
$departments[] = $row;
}
// Rollen abrufen
$roles = [];
$resultRoles = mysqli_query($GLOBALS['mysql_con'], "SELECT id, description FROM main_role");
while ($row = mysqli_fetch_assoc($resultRoles)) {
$roles[] = $row;
}
// Gespeicherte Mandanten abrufen
$savedMandants = [];
$querySavedMandants = "SELECT mandant_id FROM knowledgecenter_category_mandant WHERE category_id = $categoryId";
$resultSavedMandants = mysqli_query($GLOBALS['mysql_con'], $querySavedMandants);
while ($row = mysqli_fetch_assoc($resultSavedMandants)) {
$savedMandants[] = $row['mandant_id'];
}
// Array mit Daten für Tagify (Mandanten)
$savedMandantsData = [];
foreach ($mandants as $mandant) {
if (in_array($mandant['id'], $savedMandants)) {
$savedMandantsData[] = ['value' => $mandant['id'], 'name' => $mandant['description']];
}
}
// Gespeicherte Departments abrufen
$savedDepartments = [];
$querySavedDepartments = "SELECT department_id FROM knowledgecenter_category_department WHERE category_id = $categoryId";
$resultSavedDepartments = mysqli_query($GLOBALS['mysql_con'], $querySavedDepartments);
while ($row = mysqli_fetch_assoc($resultSavedDepartments)) {
$savedDepartments[] = $row['department_id'];
}
// Array mit Daten für Tagify (Departments)
$savedDepartmentsData = [];
foreach ($departments as $department) {
if (in_array($department['id'], $savedDepartments)) {
$savedDepartmentsData[] = ['value' => $department['id'], 'name' => $department['description']];
}
}
// Gespeicherte Rollen abrufen
$savedRoles = [];
$querySavedRoles = "SELECT role_id FROM knowledgecenter_category_role WHERE category_id = $categoryId";
$resultSavedRoles = mysqli_query($GLOBALS['mysql_con'], $querySavedRoles);
while ($row = mysqli_fetch_assoc($resultSavedRoles)) {
$savedRoles[] = $row['role_id'];
}
// Array mit Daten für Tagify (Rollen)
$savedRolesData = [];
foreach ($roles as $role) {
if (in_array($role['id'], $savedRoles)) {
$savedRolesData[] = ['value' => $role['id'], 'name' => $role['description']];
}
}
// Einrichtungen abrufen
$einrichts = [];
$resultEinrichts = mysqli_query($GLOBALS['mysql_con'], "SELECT id, description FROM organigramm_einricht ORDER BY description ASC");
while ($row = mysqli_fetch_assoc($resultEinrichts)) {
$einrichts[] = $row;
}
// Fachbereiche abrufen
$fachbereiche = [];
$resultFachbereiche = mysqli_query($GLOBALS['mysql_con'], "SELECT id, description FROM organogramm_space ORDER BY description ASC");
while ($row = mysqli_fetch_assoc($resultFachbereiche)) {
$fachbereiche[] = $row;
}
// Gespeicherte Einrichtungen abrufen
$savedEinrichts = [];
$querySavedEinrichts = "SELECT einricht_id FROM knowledgecenter_category_einricht WHERE category_id = $categoryId";
$resultSavedEinrichts = mysqli_query($GLOBALS['mysql_con'], $querySavedEinrichts);
while ($row = mysqli_fetch_assoc($resultSavedEinrichts)) {
$savedEinrichts[] = $row['einricht_id'];
}
// Verknüpfungskarte für Einrichtungen (Gruppen → Mitglieder)
$einrichtLinks = [];
$linkRes = mysqli_query($GLOBALS['mysql_con'], "SELECT einricht_id, linked_einricht_id FROM organigramm_einricht_link");
while ($lr = mysqli_fetch_assoc($linkRes)) {
$einrichtLinks[strval($lr['einricht_id'])][] = strval($lr['linked_einricht_id']);
}
// Array mit Daten für Tagify (Einrichtungen)
$savedEinrichtsData = [];
foreach ($einrichts as $einricht) {
if (in_array($einricht['id'], $savedEinrichts)) {
$savedEinrichtsData[] = ['value' => $einricht['id'], 'name' => $einricht['description']];
}
}
// Gespeicherte Fachbereiche abrufen
$savedFachbereiche = [];
$querySavedFachbereiche = "SELECT fachbereich_id FROM knowledgecenter_category_fachbereich WHERE category_id = $categoryId";
$resultSavedFachbereiche = mysqli_query($GLOBALS['mysql_con'], $querySavedFachbereiche);
while ($row = mysqli_fetch_assoc($resultSavedFachbereiche)) {
$savedFachbereiche[] = $row['fachbereich_id'];
}
// Array mit Daten für Tagify (Fachbereiche)
$savedFachbereicheData = [];
foreach ($fachbereiche as $fachbereich) {
if (in_array($fachbereich['id'], $savedFachbereiche)) {
$savedFachbereicheData[] = ['value' => $fachbereich['id'], 'name' => $fachbereich['description']];
}
}
// Kategorie auslesen
$query = "SELECT c.* FROM knowledgecenter_categories_update AS c WHERE c.id = $categoryId LIMIT 1";
$result = mysqli_query($GLOBALS['mysql_con'], $query);
$category = mysqli_fetch_assoc($result);
// Alle Sprachen aus dem System abrufen
$languagesQuery = "SELECT id, code, name FROM main_language ORDER BY id ASC";
$languagesResult = mysqli_query($GLOBALS['mysql_con'], $languagesQuery);
$languages = [];
while ($langRow = mysqli_fetch_assoc($languagesResult)) {
$languages[] = $langRow;
}
// Für jede Sprache die existierende Übersetzung abrufen
$translations = [];
foreach ($languages as $lang) {
$langId = $lang['id'];
$transQuery = "SELECT title, image, is_navigation_item
FROM knowledgecenter_category_translations
WHERE category_id = $categoryId AND language_id = $langId LIMIT 1";
$transResult = mysqli_query($GLOBALS['mysql_con'], $transQuery);
$translations[$lang['code']] = mysqli_fetch_assoc($transResult) ?: [
'title' => '',
'image' => '',
'is_navigation_item' => 0
];
}
// Alle verknüpften Unterkategorien abrufen
$linkedQuery = "SELECT c.id, t.title
FROM knowledgecenter_category_links AS l
JOIN knowledgecenter_categories_update AS c ON l.child_category_id = c.id
LEFT JOIN knowledgecenter_category_translations AS t
ON t.category_id = c.id AND t.language_id = $languageId
WHERE l.parent_category_id = $categoryId
ORDER BY l.sorting ASC";
$linkedResult = mysqli_query($GLOBALS['mysql_con'], $linkedQuery);
$subcategories = [];
while ($row = mysqli_fetch_assoc($linkedResult)) {
$subcategories[] = $row;
}
// Erzeuge eine Liste der verknüpften IDs
$selectedIds = [];
foreach ($subcategories as $sub) {
$selectedIds[] = $sub['id'];
}
$selectedIdsStr = empty($selectedIds) ? '0' : implode(',', $selectedIds);
// Alle verfügbaren Unterkategorien abrufen
$availableQuery = "SELECT id,
(SELECT title FROM knowledgecenter_category_translations
WHERE category_id = knowledgecenter_categories_update.id
AND language_id = $languageId LIMIT 1) as title
FROM knowledgecenter_categories_update
WHERE id NOT IN ($selectedIdsStr) AND id <> $categoryId
ORDER BY title ASC";
$availableResult = mysqli_query($GLOBALS['mysql_con'], $availableQuery);
// ***** Neuer Code: Überprüfen in wie vielen Parent-Kategorien die aktuelle Kategorie verknüpft wurde *****
$parentQuery = "SELECT p.id, pt.title
FROM knowledgecenter_category_links AS l
JOIN knowledgecenter_categories_update AS p ON l.parent_category_id = p.id
LEFT JOIN knowledgecenter_category_translations AS pt
ON pt.category_id = p.id AND pt.language_id = $languageId
WHERE l.child_category_id = $categoryId";
$resultParents = mysqli_query($GLOBALS['mysql_con'], $parentQuery);
$parentCategories = [];
while ($row = mysqli_fetch_assoc($resultParents)) {
$parentCategories[] = $row;
}
$parentCount = count($parentCategories);
?>
<style>
.perm-row { margin-bottom: 16px; }
.perm-row label { display: block; font-weight: 500; font-size: 13px; color: #374151; margin-bottom: 6px; }
.perm-row .tagify { width: 100%; }
</style>
<div class="cardform_containern">
<div class="cardform">
<div class="card-body">
<div class="tabbar_with_button">
<a href="?action=Categories&embedded=<?= $isEmbedded ?>">←</a>
<ul class="nav nav-tabs" id="languageTabs" role="tablist">
<?php foreach ($languages as $index => $lang): ?>
<li class="nav-item" role="presentation">
<button class="nav-link <?= ($index === 0) ? 'active' : '' ?>" id="tab-<?= $lang['code'] ?>"
data-bs-toggle="tab" data-bs-target="#lang-<?= $lang['code'] ?>" type="button" role="tab">
<?= htmlspecialchars(strtoupper($lang['code'])) ?>
</button>
</li>
<?php endforeach; ?>
</ul>
<div>
<button id="message" style="opacity:0;"></button>
</div>
</div>
<div class="tab-content-languages" id="languageTabsContent">
<?php foreach ($languages as $index => $lang):
$trans = $translations[$lang['code']];
?>
<div class="tab-pane fade <?= ($index === 0) ? 'show active' : '' ?>" id="lang-<?= $lang['code'] ?>"
role="tabpanel">
<form class="translationForm" data-lang="<?= $lang['id'] ?>" data-lang-code="<?= $lang['code'] ?>">
<div class="label title-<?php echo $lang['code']; ?>">
<label for="title-<?php echo $lang['code']; ?>">Titel
(<?php echo htmlspecialchars($lang['code']); ?>)</label>
</div>
<div class="input title-<?php echo $lang['code']; ?>">
<input required name="title-<?php echo $lang['code']; ?>"
id="title-<?php echo $lang['code']; ?>" class="text" type="text"
value="<?php echo htmlspecialchars($translations[$lang['code']]['title']); ?>">
</div>
<?php if ($categoryId != 0): ?>
<div class="single_image_group">
<img id="preview-<?= $lang['code'] ?>"
src="<?= $trans['image'] ? '/userdata/' . htmlspecialchars($trans['image']) : '/userdata/intranet/mandant/platzhalter_kategorie.png' ?>"
alt="Bildvorschau" style="max-width: 100px; min-height: 60px;
border-radius: 5px; <?= $trans['image'] ? '' : 'background-color:#f3f3f3;' ?>">
<input type="hidden" name="image-<?= $lang['code'] ?>" id="image-<?= $lang['code'] ?>"
value="<?= htmlspecialchars($trans['image']) ?>">
<div class="post-action-btn green openFileGallery" data-ajax-function="saveCategoryAjax"
data-target="image-<?= $lang['code'] ?>">
+</div>
<div class="post-action-btn clearImage" data-target="image-<?= $lang['code'] ?>">✕</div>
</div>
<?php endif; ?>
</form>
</div>
<?php endforeach; ?>
</div>
<?php if ($categoryId != 0): ?>
<div class="category_sub_select">
<div class="category_sub_select_container">
<h3><?= $translation->get("connected_subcategories") ?></h3>
<input type="search" class="subcategory-search" id="search-selected"
placeholder="Suche in verknüpften Kategorien">
<ul id="selectedSubcategories" class="list-group connectedSortable">
<?php if (empty($subcategories)): ?>
<li class="list-group-item empty-hint">
<?= $translation->get("no_connected_subcategories") ?>
</li>
<?php else: ?>
<?php foreach ($subcategories as $sub): ?>
<li class="list-group-item" data-id="<?= $sub['id'] ?>">
<div>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-grip-vertical" viewBox="0 0 16 16">
<path
d="M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0" />
</svg>
<?= htmlspecialchars($sub['title'] ?? "Fehlende Übersetzung ID {$sub['id']}") ?>
</div>
<button title="<?= $translation->get("delete") ?>" type="button"
class="unlinkSubcategory">✕</button>
</li>
<?php endforeach; ?>
<?php endif; ?>
</ul>
</div>
<div class="category_sub_select_container">
<h3><?= $translation->get("available_categories") ?></h3>
<input type="search" class="subcategory-search" id="search-available"
placeholder="Suche in verfügbaren Kategorien">
<ul id="availableSubcategories" class="list-group connectedSortable">
<?php if (mysqli_num_rows($availableResult) == 0): ?>
<li class="list-group-item empty-hint">
<?= $translation->get("all_available_categories_used") ?>
</li>
<?php else: ?>
<?php while ($row = mysqli_fetch_assoc($availableResult)): ?>
<li class="list-group-item" data-id="<?= $row['id'] ?>">
<div>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-grip-vertical" viewBox="0 0 16 16">
<path
d="M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0" />
</svg>
<?= htmlspecialchars($row['title'] ?: "Fehlende Übersetzung ID {$row['id']}") ?>
</div>
</li>
<?php endwhile; ?>
<?php endif; ?>
</ul>
</div>
</div>
<?php endif; ?>
</div>
</div>
<div class="aside">
<div class="aside_section">
<div class="aside_toolbar">
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
<button class="post-action-btn" id="saveAllTranslations" style="background: #14b814;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-check" viewBox="0 0 16 16">
<path
d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z">
</path>
</svg>
</button>
<?php } ?>
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
<button class="post-action-btn red" id="deleteCategory">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-trash3-fill" viewBox="0 0 16 16">
<path
d="M11 1.5v1h3.5a.5.5 0 0 1 0 1h-.538l-.853 10.66A2 2 0 0 1 11.115 16h-6.23a2 2 0 0 1-1.994-1.84L2.038 3.5H1.5a.5.5 0 0 1 0-1H5v-1A1.5 1.5 0 0 1 6.5 0h3A1.5 1.5 0 0 1 11 1.5m-5 0v1h4v-1a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5M4.5 5.029l.5 8.5a.5.5 0 1 0 .998-.06l-.5-8.5a.5.5 0 1 0-.998.06m6.53-.528a.5.5 0 0 0-.528.47l-.5 8.5a.5.5 0 0 0 .998.058l.5-8.5a.5.5 0 0 0-.47-.528M8 4.5a.5.5 0 0 0-.5.5v8.5a.5.5 0 0 0 1 0V5a.5.5 0 0 0-.5-.5" />
</svg>
</button>
<?php } ?>
</div>
</div>
<div class="aside_section">
<label for="stateCheckbox">
<?= $translation->get("active_state") ?>
<input type="checkbox" id="stateCheckbox" name="state" value="1" <?= $state === 1 ? 'checked' : '' ?>>
</label>
</div>
<?php $is_navigation_item = $translations[$languages[0]['code']]['is_navigation_item'] ?? 0; ?>
<div class="aside_section">
<label for="isNavigationItem">
<?= $translation->get("create_navigation_menu") ?>
<input type="checkbox" id="isNavigationItem" name="is_navigation_item" value="1"
<?= $is_navigation_item == 1 ? 'checked' : '' ?>>
</label>
</div>
<div class="aside_section">
<label for="color_hex">
<?= $translation->get("color_hex") ?>
<input type="color" id="color_hex" name="color_hex" value="<?= $category['color_hex'] ?? '#3B82F6FF' ?>">
</label>
</div>
<?php $enable_post_color_pickers = isset($category['enable_post_color_pickers']) ? (int) $category['enable_post_color_pickers'] : 0; ?>
<div class="aside_section">
<label for="enablePostColorPickersCheckbox">
<?= $translation->get("enable_post_color_pickers") ?>
<input type="checkbox" id="enablePostColorPickersCheckbox" name="enable_post_color_pickers" value="1"
<?= $enable_post_color_pickers === 1 ? 'checked' : '' ?>>
</label>
</div>
<?php $betriebsrat_editable = isset($category['betriebsrat_editable']) ? (int) $category['betriebsrat_editable'] : 0; ?>
<div class="aside_section">
<label for="betriebsratEditableCheckbox">
Für Betriebsrat bearbeitbar
<input type="checkbox" id="betriebsratEditableCheckbox" name="betriebsrat_editable" value="1"
<?= $betriebsrat_editable === 1 ? 'checked' : '' ?>>
</label>
</div>
<?php if (!empty($categoryId)): ?>
<?php
$formatter = new IntlDateFormatter(
'de_DE',
IntlDateFormatter::LONG,
IntlDateFormatter::SHORT
);
$timestamp = strtotime($category["modified_at"]);
?>
<div class="aside_section">
<small><?= $translation->get("connected_subcategories") ?> (<?= $parentCount ?>)</small>
<?php if ($parentCount > 0): ?>
<ul class="connected_categories">
<?php foreach ($parentCategories as $parent): ?>
<li>
<a target="_blank" href="?action=Categories&detail=<?= $parent['id'] ?>">
<?= htmlspecialchars($parent['title'] ?: "Kategorie-ID " . $parent['id']) ?> ↗
</a>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p style="font-style: italic;"><?= $translation->get("not_connected_as_subcategory") ?></p>
<?php endif; ?>
</div>
<div class="aside_section">
<small><?= $translation->get("last_change_on") ?></small>
<p style="font-style: italic;">
<?= $formatter->format($timestamp) ?> - <?= $category["modified_by"] ?>
</p>
</div>
<?php endif; ?>
</div>
</div>
<?php if ($categoryId != 0): ?>
<div class="cardform_containern perm-fullwidth">
<div class="cardform" style="flex:1;">
<div class="card-body">
<h3><?= $translation->get("permissions_and_filters") ?></h3>
<div class="perm-row">
<label for="selected_mandants"><?= $translation->get("mandants") ?></label>
<input type="text" id="selected_mandants" name="selected_mandants"
value='<?= htmlspecialchars(json_encode($savedMandantsData)); ?>'>
</div>
<div class="perm-row">
<label for="selected_departments"><?= $translation->get("departments") ?></label>
<input type="text" id="selected_departments" name="selected_departments"
value='<?= htmlspecialchars(json_encode($savedDepartmentsData)); ?>'>
</div>
<div class="perm-row">
<label for="selected_roles"><?= $translation->get("roles") ?></label>
<input type="text" id="selected_roles" name="selected_roles"
value='<?= htmlspecialchars(json_encode($savedRolesData)); ?>'>
</div>
<div class="perm-row">
<label for="selected_einrichts">Einrichtungen</label>
<input type="text" id="selected_einrichts" name="selected_einrichts"
value='<?= htmlspecialchars(json_encode($savedEinrichtsData)); ?>'>
</div>
<div class="perm-row">
<label for="selected_fachbereiche">Fachbereiche</label>
<input type="text" id="selected_fachbereiche" name="selected_fachbereiche"
value='<?= htmlspecialchars(json_encode($savedFachbereicheData)); ?>'>
</div>
</div>
</div>
</div>
<?php endif; ?>
<input type="hidden" id="category_id" value="<?= $categoryId ?>">
<input type="hidden" id="modified_by"
value="<?= $GLOBALS["main_contact"]["name"] ?> (<?= $GLOBALS["main_contact"]["id"] ?>)">
<script>
$(function () {
// Funktion zum sequentiellen Speichern der Übersetzungen
window.saveCategoryAjax = function (callback) {
var forms = $(".translationForm");
var totalForms = forms.length;
var currentIndex = 0;
function processNextForm() {
if (currentIndex >= totalForms) {
if (callback) callback();
return; // Alle Formulare wurden bearbeitet
}
var $form = $(forms[currentIndex]);
var langId = $form.data("lang");
var langCode = $form.data("lang-code");
var title = $form.find("input[name='title-" + langCode + "']").val();
var image = $form.find("input[name='image-" + langCode + "']").val();
var state = $('#stateCheckbox').is(':checked') ? 1 : 0;
var isNavigationItem = $('#isNavigationItem').is(':checked') ? 1 : 0;
var enablePostColorPickers = $('#enablePostColorPickersCheckbox').is(':checked') ? 1 : 0;
var betriebsratEditable = $('#betriebsratEditableCheckbox').is(':checked') ? 1 : 0;
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "update_category_detail",
id: $("#category_id").val(),
title: title,
image: image,
language_id: langId,
modified_by: $("#modified_by").val(),
state: state,
is_navigation_item: isNavigationItem,
enable_post_color_pickers: enablePostColorPickers,
color_hex: $('#color_hex').val(),
betriebsrat_editable: betriebsratEditable
}, function (response) {
if (!response.success) {
showMessage("Fehler beim Speichern der Übersetzung (" + langCode + "): " + response.error, false);
} else {
var newImagePath = $("input[name='image-" + langCode + "']").val();
var $previewImg = $("#preview-" + langCode);
if (newImagePath) {
$previewImg.attr("src", "/userdata/" + newImagePath).show();
}
// Wenn neuer Datensatz, ID setzen und URL anpassen
if ($("#category_id").val() == 0 && response.category_id) {
$("#category_id").val(response.category_id);
history.replaceState({}, '', "?action=Categories&detail=" + response.category_id + "&embedded=<?= htmlspecialchars($isEmbedded, ENT_QUOTES, 'UTF-8') ?>");
location.reload();
}
showMessage("Erfolgreich gespeichert", true);
}
currentIndex++;
processNextForm(); // Nächsten Request starten
}, "json");
}
processNextForm();
}
// Funktion zum Speichern der Tagify-Daten (Mandanten, Departments, Rollen, Einrichtungen, Fachbereiche)
function saveTagifyData() {
var mandants = JSON.stringify(tagifyMandants.value.map(function (t) { return t.value; }));
var departments = JSON.stringify(tagifyDepartments.value.map(function (t) { return t.value; }));
var roles = JSON.stringify(tagifyRoles.value.map(function (t) { return t.value; }));
var einrichts = JSON.stringify(tagifyEinrichts.value.map(function (t) { return t.value; }));
var fachbereiche = JSON.stringify(tagifyFachbereiche.value.map(function (t) { return t.value; }));
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "update_category_links",
id: $("#category_id").val(),
mandants: mandants,
departments: departments,
roles: roles,
einrichts: einrichts,
fachbereiche: fachbereiche,
modified_by: $("#modified_by").val()
}, function (response) {
if (!response.success) {
showMessage("Fehler beim Speichern der Verknüpfungen: " + response.error, false);
} else {
tagifyMandants.removeAllTags();
tagifyMandants.addTags(response.mandants);
tagifyDepartments.removeAllTags();
tagifyDepartments.addTags(response.departments);
tagifyRoles.removeAllTags();
tagifyRoles.addTags(response.roles);
tagifyEinrichts.removeAllTags();
tagifyEinrichts.addTags(response.einrichts);
tagifyFachbereiche.removeAllTags();
tagifyFachbereiche.addTags(response.fachbereiche);
}
}, "json");
}
// Handler für "Speichern"-Button
$("#saveAllTranslations").on("click", function () {
saveCategoryAjax();
<?php if ($categoryId != 0): ?>
saveTagifyData();
<?php endif; ?>
});
// Sortable-Initialisierung für beide Unterkategorie-Listen
$("#selectedSubcategories, #availableSubcategories").sortable({
connectWith: ".connectedSortable",
placeholder: "ui-state-highlight",
update: function (event, ui) {
if ($(this).attr("id") === "selectedSubcategories") {
var sortedIDs = $(this).sortable("toArray", { attribute: "data-id" });
$.ajax({
url: "/module/knowledgecenter_update/Ajax/Ajax.php",
type: "POST",
dataType: "json",
data: {
action: "update_subcategory_sorting",
parent_id: <?= $categoryId ?>,
order: sortedIDs
},
success: function (response) {
if (response.success) {
showMessage("Sortierung aktualisiert!", true);
} else {
showMessage("Fehler beim Sortieren: " + response.error, false);
}
}
});
}
},
receive: function (event, ui) {
// Wird ein Element in die linke Liste (Ausgewählt) gezogen
if ($(this).attr("id") === "selectedSubcategories") {
var childId = ui.item.data("id");
if (ui.item.find(".unlinkSubcategory").length === 0) {
ui.item.append('<button type="button" class="unlinkSubcategory">✕</button>');
}
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "link_subcategory",
parent_id: <?= $categoryId ?>,
child_id: childId
}, function (response) {
if (response.success) {
showMessage("Kategorie verknüpft!", true);
updateEmptyHints();
} else {
showMessage("Fehler beim Verknüpfen: " + response.error, false);
updateEmptyHints();
}
}, "json");
}
// Wird ein Element in die rechte Liste (Verfügbar) gezogen
if ($(this).attr("id") === "availableSubcategories") {
var childId = ui.item.data("id");
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "unlink_subcategory",
parent_id: <?= $categoryId ?>,
child_id: childId
}, function (response) {
if (response.success) {
showMessage("Kategorie entfernt!", true);
} else {
showMessage("Fehler beim Entfernen: " + response.error, false);
}
}, "json");
ui.item.find(".unlinkSubcategory").remove();
}
}
}).disableSelection();
// Event-Handler für das Entfernen einer Unterkategorie per Klick
$("#selectedSubcategories").on("click", ".unlinkSubcategory", function (e) {
e.stopPropagation();
var li = $(this).closest("li");
var childId = li.data("id");
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "unlink_subcategory",
parent_id: <?= $categoryId ?>,
child_id: childId
}, function (response) {
if (response.success) {
li.fadeOut(function () {
li.find(".unlinkSubcategory").remove();
li.appendTo("#availableSubcategories").fadeIn();
showMessage("Kategorie entfernt!", true);
updateEmptyHints();
});
} else {
showMessage("Fehler beim Entfernen: " + response.error, false);
updateEmptyHints();
}
}, "json");
});
// Suche für verknüpfte Unterkategorien
$("#search-selected").on("keyup", function () {
var value = $(this).val().toLowerCase();
$("#selectedSubcategories li").filter(function () {
$(this).toggle($(this).text().toLowerCase().indexOf(value) > -1);
});
});
// Suche für verfügbare Kategorien
$("#search-available").on("keyup", function () {
var value = $(this).val().toLowerCase();
$("#availableSubcategories li").filter(function () {
$(this).toggle($(this).text().toLowerCase().indexOf(value) > -1);
});
});
});
$(document).ready(function () {
// Stelle sicher, dass der erste Tab und das zugehörige Content-Panel aktiv sind
$('#languageTabs button').first().addClass('active');
$('#languageTabsContent .tab-pane').first().addClass('show active');
// Bei Tab-Klick: Aktive Klasse wechseln
$('#languageTabs button').on('click', function () {
var target = $(this).attr('data-bs-target');
$('#languageTabs button').removeClass('active');
$('#languageTabsContent .tab-pane').removeClass('show active');
$(this).addClass('active');
$(target).addClass('show active');
});
});
// Funktion zur Aktualisierung der leeren Hinweistexte in den Unterkategorie-Listen
function updateEmptyHints() {
const selected = $("#selectedSubcategories");
const available = $("#availableSubcategories");
if (selected.find("li:not(.empty-hint)").length === 0) {
if (selected.find(".empty-hint").length === 0) {
selected.append('<li class="list-group-item empty-hint">Aktuell wurde noch keine Unterkategorie verknüpft. Ziehen Sie einfach per Drag & Drop eine Kategorie in das weiße Feld.</li>');
}
} else {
selected.find(".empty-hint").remove();
}
if (available.find("li:not(.empty-hint)").length === 0) {
if (available.find(".empty-hint").length === 0) {
available.append('<li class="list-group-item empty-hint">Alle möglichen Kategorien wurden bereits verknüpft.</li>');
}
} else {
available.find(".empty-hint").remove();
}
}
<?php if ($categoryId != 0): ?>
// Initialisierung der Tagify-Objekte
var mandantsData = <?php echo json_encode(array_map(function ($item) {
return ['value' => $item['id'], 'name' => $item['description']];
}, $mandants)); ?>;
var departmentsData = <?php echo json_encode(array_map(function ($item) {
return ['value' => $item['id'], 'name' => $item['description']];
}, $departments)); ?>;
var rolesData = <?php echo json_encode(array_map(function ($item) {
return ['value' => $item['id'], 'name' => $item['description']];
}, $roles)); ?>;
var tagifyMandants = new Tagify(document.getElementById('selected_mandants'), {
whitelist: mandantsData,
tagTextProp: 'name',
enforceWhitelist: true,
dropdown: {
enabled: 0,
maxItems: Infinity,
closeOnSelect: false,
searchKeys: ["name"]
},
templates: {
dropdownItem: function (tagData) {
return `<div ${this.getAttributes(tagData)}
class="tagify__dropdown__item">
<span>${tagData.name}</span>
</div>`;
}
}
});
var tagifyDepartments = new Tagify(document.getElementById('selected_departments'), {
whitelist: departmentsData,
tagTextProp: 'name',
enforceWhitelist: true,
dropdown: {
enabled: 0,
maxItems: Infinity,
searchKeys: ["name"]
},
templates: {
dropdownItem: function (tagData) {
return `<div ${this.getAttributes(tagData)}
class="tagify__dropdown__item">
<span>${tagData.name}</span>
</div>`;
}
}
});
var tagifyRoles = new Tagify(document.getElementById('selected_roles'), {
whitelist: rolesData,
tagTextProp: 'name',
enforceWhitelist: true,
dropdown: {
enabled: 0,
maxItems: Infinity,
searchKeys: ["name"]
},
templates: {
dropdownItem: function (tagData) {
return `<div ${this.getAttributes(tagData)}
class="tagify__dropdown__item">
<span>${tagData.name}</span>
</div>`;
}
}
});
var einrichtsData = <?php echo json_encode(array_map(function ($item) {
return ['value' => $item['id'], 'name' => $item['description']];
}, $einrichts)); ?>;
var fachbereicheData = <?php echo json_encode(array_map(function ($item) {
return ['value' => $item['id'], 'name' => $item['description']];
}, $fachbereiche)); ?>;
var tagifyEinrichts = new Tagify(document.getElementById('selected_einrichts'), {
whitelist: einrichtsData,
tagTextProp: 'name',
enforceWhitelist: true,
dropdown: {
enabled: 0,
maxItems: Infinity,
searchKeys: ["name"]
},
templates: {
dropdownItem: function (tagData) {
return `<div ${this.getAttributes(tagData)}
class="tagify__dropdown__item">
<span>${tagData.name}</span>
</div>`;
}
}
});
var einrichtLinks = <?= json_encode($einrichtLinks) ?>;
tagifyEinrichts.on('add', function(e) {
var addedId = String(e.detail.data.value);
(einrichtLinks[addedId] || []).forEach(function(lid) {
var exists = tagifyEinrichts.value.some(function(t) { return String(t.value) === lid; });
if (!exists) {
var item = einrichtsData.find(function(d) { return String(d.value) === lid; });
if (item) tagifyEinrichts.addTags([item]);
}
});
});
var tagifyFachbereiche = new Tagify(document.getElementById('selected_fachbereiche'), {
whitelist: fachbereicheData,
tagTextProp: 'name',
enforceWhitelist: true,
dropdown: {
enabled: 0,
maxItems: Infinity,
searchKeys: ["name"]
},
templates: {
dropdownItem: function (tagData) {
return `<div ${this.getAttributes(tagData)}
class="tagify__dropdown__item">
<span>${tagData.name}</span>
</div>`;
}
}
});
<?php endif; ?>
// Handler für das Löschen einer Kategorie
$("#deleteCategory").on("click", function () {
if (confirm("Möchten Sie diese Kategorie wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.")) {
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "delete_category",
id: $("#category_id").val()
}, function (response) {
if (response.success) {
showMessage("Kategorie erfolgreich gelöscht!", true);
window.location.href = "?action=Categories&embedded=<?= $isEmbedded ?>";
} else {
showMessage("Fehler beim Löschen: " + response.error, false);
}
}, "json");
}
});
</script>

View File

@@ -0,0 +1,190 @@
<?php
// ------------------------------
// Category List Form mit Unterkategorien und Sortable
// ------------------------------
$formname = "categories_listform";
/**
* Holt alle Hauptkategorien mit Übersetzung und verknüpften Unterkategorien
*/
function fetchCategories($languageId)
{
$query =
"SELECT
c.id,
t.title,
c.sorting,
c.state,
GROUP_CONCAT(
CONCAT(sc.id,'::',st.title,'::',sc.state)
ORDER BY l.sorting ASC
SEPARATOR '||'
) AS subcats_data
FROM knowledgecenter_categories_update AS c
LEFT JOIN knowledgecenter_category_translations AS t
ON t.category_id = c.id AND t.language_id = $languageId
LEFT JOIN knowledgecenter_category_links AS l
ON l.parent_category_id = c.id
LEFT JOIN knowledgecenter_categories_update AS sc
ON sc.id = l.child_category_id
LEFT JOIN knowledgecenter_category_translations AS st
ON st.category_id = sc.id AND st.language_id = $languageId
GROUP BY c.id
ORDER BY c.sorting ASC";
$result = mysqli_query($GLOBALS['mysql_con'], $query);
$categories = [];
while ($row = mysqli_fetch_assoc($result)) {
// Unterkategorien parsen
$row['subcategories'] = [];
if (!empty($row['subcats_data'])) {
foreach (explode('||', $row['subcats_data']) as $part) {
list($id, $title, $state) = explode('::', $part, 3);
$row['subcategories'][] = [
'id' => (int) $id,
'title' => $title,
'state' => (int) $state
];
}
}
$categories[] = $row;
}
return $categories;
}
// Kategorien abrufen
$categories = fetchCategories($languageId);
?>
<form action="" id="<?= $formname ?>" name="<?= $formname ?>" method="post">
<div class="titlebar">
<div>
<?php if (!$isEmbedded): ?>
<a title="<?= $translation->get("back") ?>" href="?action=List">←</a>
<?php endif; ?>
<h2><?= $translation->get("category_list") ?></h2>
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1): ?>
<span title="<?= $translation->get("new_wiki_category") ?>" id="btnNewCategory"
class="post-action-btn green">+</span>
<?php endif; ?>
</div>
<button style="opacity:0;" id="message"></button>
<div>
<input type="search" id="searchCategories" placeholder="<?= $translation->get("search_placeholder") ?>">
</div>
</div>
<ul id="sortable" class="list-group">
<?php foreach ($categories as $cat): ?>
<li data-id="<?= $cat['id'] ?>">
<div>
<!-- Grip-Icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-grip-vertical" viewBox="0 0 16 16">
<path
d="M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0" />
</svg>
<span class="status <?= $cat['state'] == 1 ? 'active' : 'inactive' ?>">
</span>
<?= htmlspecialchars($cat['title'] ?? "Kein Inhalt für diese Sprache hinterlegt ID: " . $cat['id']) ?>
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1): ?>
<span title="<?= $translation->get("delete") ?>"
class="deleteCategory post-action-btn-small red">&times;</span>
<?php endif; ?>
</div>
<?php if (!empty($cat['subcategories'])): ?>
<ul class="subcat-list">
<?php foreach ($cat['subcategories'] as $sub): ?>
<li class="subcategory-item" data-id="<?= $sub['id'] ?>">
<?= htmlspecialchars($sub['title']) ?>
<span class="status <?= $sub['state'] == 1 ? 'active' : 'inactive' ?>">
</span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</form>
<script>
$(function () {
// Suche in Haupt- und Unterkategorien
$("#searchCategories").on("input", function () {
var query = $(this).val().toLowerCase();
$("#sortable li").each(function () {
var text = $(this).text().toLowerCase();
$(this).toggle(text.indexOf(query) !== -1);
});
});
// Nur Top-Level li sortierbar
$("#sortable").sortable({
items: "> li",
cancel: ".subcat-list",
update: function (event, ui) {
var sortedIDs = $(this).sortable("toArray", { attribute: "data-id" });
$.ajax({
url: "/module/knowledgecenter_update/Ajax/Ajax.php",
type: "POST",
dataType: "json",
data: {
action: "update_categories_sorting",
order: sortedIDs
}
})
.done(function (response) {
if (response.success) {
showMessage("Sortierung erfolgreich gespeichert", true);
} else {
showMessage("Fehler: " + response.error, false);
}
})
.fail(function (jqXHR, textStatus, errorThrown) {
showMessage("Serverfehler beim Speichern: " + textStatus, false);
});
}
});
// Doppelklick auf Hauptkategorie
$("#sortable").on("dblclick", "> li", function () {
var id = $(this).data("id");
window.location.href = "?action=Categories&detail=" + id + "&embedded=<?= $isEmbedded ?>";
});
// Doppelklick auf Unterkategorie
$("#sortable").on("dblclick", ".subcategory-item", function (e) {
e.stopPropagation();
var id = $(this).data("id");
window.location.href = "?action=Categories&detail=" + id + "&embedded=<?= $isEmbedded ?>";
});
// Neu-Kategorie-Button
$("#btnNewCategory").on("click", function () {
window.location.href = "?action=Categories&detail=new&embedded=<?= $isEmbedded ?>";
});
// Löschen einer Kategorie
$("#sortable").on("click", ".deleteCategory", function (e) {
e.stopPropagation();
var li = $(this).closest("li");
var categoryId = li.data("id");
if (confirm("Möchten Sie diese Kategorie wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.")) {
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "delete_category",
id: categoryId
}, function (response) {
if (response.success) {
showMessage("Kategorie erfolgreich gelöscht!", true);
li.fadeOut(function () { li.remove(); });
} else {
showMessage("Fehler beim Löschen: " + response.error, false);
}
}, "json");
}
});
});
</script>

View File

@@ -0,0 +1,511 @@
<?php
// ini_set('display_errors', 1);
// ini_set('display_startup_errors', 1);
// error_reporting(E_ALL);
if (empty($GLOBALS['allowedMandants'])) {
echo "<div style='padding:1em; background:#ffe0e0; border:1px solid red; color:#a00000;'>
<strong>Hinweis:</strong> Sie haben keine Mandantenzuweisung. Bitte wenden Sie sich an Ihren Intranet-Administrator.
</div>";
exit;
}
if (empty($GLOBALS['allowedDepartments'])) {
echo "<div style='padding:1em; background:#ffe0e0; border:1px solid red; color:#a00000;'>
<strong>Hinweis:</strong> Sie haben keine Abteilungszuweisung. Bitte wenden Sie sich an Ihren Intranet-Administrator.
</div>";
exit;
}
if (empty($GLOBALS['allowedRoles'])) {
echo "<div style='padding:1em; background:#ffe0e0; border:1px solid red; color:#a00000;'>
<strong>Hinweis:</strong> Sie haben keine Rollen-Zuweisung. Bitte wenden Sie sich an Ihren Intranet-Administrator.
</div>";
exit;
}
// Standard-Redirect-URL (zum Beispiel für Navigation)
$redirectURL = isset($_GET['redirectURL']) ? $_GET['redirectURL'] : '?action=List';
$_kcBrOnly = !is_wiki_redakteur() && is_wiki_betriebsrat();
?>
<script>
(function () {
var mode = localStorage.getItem("categoryGridView");
if (mode === "list") {
document.documentElement.classList.add("list_view");
}
})();
</script>
<form action="" id="<?= $formname = "categories_posts_listform" ?>" name="<?= $formname ?>" method="post">
<div class="titlebar">
<div class="searchbar">
<?php if (has_wiki_edit_permission()) { ?>
<?= button("post_list", $translation->get("post_list"), $formname, "sendRequest('PostList', true)"); ?>
<?php } ?>
<?php if (is_wiki_redakteur()) { ?>
<?= button("category_list", $translation->get("category_list"), $formname, "sendRequest('Categories', true)"); ?>
<?php } ?>
<div id="changeCategoryGridView">
</div>
</div>
<div class="titlebar-actions">
<?php
// "Beitrag erstellen" für Redakteur immer, für Betriebsrat nur in freigegebenen Kategorien
// (inkl. Vererbung: Unterkategorien einer freigegebenen Kategorie gelten ebenfalls)
$showCreateBtn = false;
if (is_wiki_redakteur()) {
$showCreateBtn = true;
} elseif ($_kcBrOnly && isset($_GET['detail'])) {
$showCreateBtn = is_category_betriebsrat_editable((int)$_GET['detail']);
}
if ($showCreateBtn) { ?>
<a href="?action=NewPost&InitialCategory=<?= (int)($_GET["detail"] ?? 0) ?>"
title="<?= $translation->get("new_wiki_post") ?>" role="button" class="green">Beitrag erstellen +</a>
<?php } ?>
</div>
</div>
<div id="searchResults"></div>
<?php
$placeholderCategory = '/userdata/01_Basic/platzhalter_bild_kategorie.jpg';
$placeholderPost = '/userdata/01_Basic/platzhalter_bild.jpg';
if (isset($_GET['detail'])) {
// Detailansicht einer Kategorie:
$categoryId = (int) $_GET['detail'];
$sql = "SELECT c.*, t.title, t.image
FROM knowledgecenter_categories_update AS c
LEFT JOIN knowledgecenter_category_translations AS t
ON c.id = t.category_id AND t.language_id = $languageId
WHERE c.id = $categoryId LIMIT 1";
$res = mysqli_query($GLOBALS['mysql_con'], $sql);
if ($res && mysqli_num_rows($res) > 0) {
$category = mysqli_fetch_assoc($res);
if (empty($category['is_single_page']) || (int) $category['is_single_page'] !== 1) {
echo "<div class='category_page_titlebar'>";
echo '<a title="' . $translation->get('back') . '" href="' . htmlspecialchars($redirectURL, ENT_QUOTES, 'UTF-8') . '">←</a>';
echo "<div class='category_page_title'>" . htmlspecialchars($category['title'] ?: "Keine Übersetzung vorhanden") . "</div>";
echo "</div>";
}
}
echo "<script>window.kcCategoryTitle = " . json_encode(isset($category['title']) && $category['title'] !== '' ? $category['title'] : null) . ";</script>";
// Dynamische Sortierung für Posts abhängig von c.order_posts_by (Whitelist)
$orderBy = 'pv.title';
$orderDir = 'ASC';
if (!empty($category['order_posts_by'])) {
switch (trim($category['order_posts_by'])) {
case 'created_at':
$orderBy = 'kp.created_at';
$orderDir = 'DESC'; // bei Bedarf auf DESC ändern
break;
case 'modified_at':
$orderBy = 'pv.modified_at';
$orderDir = 'DESC'; // bei Bedarf auf DESC ändern
break;
case 'title':
$orderBy = 'pv.title';
$orderDir = 'ASC';
break;
// weitere erlaubte Felder können hier explizit aufgenommen werden
default:
// unbekannter Wert -> Default auf Titel
$orderBy = 'pv.title';
$orderDir = 'ASC';
}
}
$extraGridClass = (isset($category['is_single_page']) && (int) $category['is_single_page'] === 1) ? ' single_page' : ' multi_page';
echo "<div class='categories_grid{$extraGridClass}'>";
/**
* Unterkategorien: Wir verwenden LEFT JOINs, um zu filtern.
* Es wird geprüft, ob in der Abteilungstabelle (cd), Mandantentabelle (cm) und Rollentabelle (cr)
* entweder kein Datensatz vorliegt (NULL) oder ob der Wert in den erlaubten Werten enthalten ist.
*/
$sqlSubs = "
SELECT
c.id,
t.title,
t.description,
t.image
FROM
knowledgecenter_category_links AS kcl
JOIN
knowledgecenter_categories_update AS c ON kcl.child_category_id = c.id
LEFT JOIN
knowledgecenter_category_translations AS t ON c.id = t.category_id AND t.language_id = $languageId
LEFT JOIN
knowledgecenter_category_department AS cd ON c.id = cd.category_id
LEFT JOIN
knowledgecenter_category_mandant AS cm ON c.id = cm.category_id
LEFT JOIN
knowledgecenter_category_role AS cr ON c.id = cr.category_id
LEFT JOIN
knowledgecenter_category_einricht AS ce ON c.id = ce.category_id
LEFT JOIN
knowledgecenter_category_fachbereich AS cf ON c.id = cf.category_id
WHERE
kcl.parent_category_id = $categoryId
AND c.state = 1
AND ( cd.department_id IS NULL OR cd.department_id IN ($allowedDepartments) )
AND ( cm.mandant_id IS NULL OR cm.mandant_id IN ($allowedMandants) )
AND ( cr.role_id IS NULL OR cr.role_id IN ($allowedRoles) )
AND ( ce.einricht_id IS NULL OR ce.einricht_id IN ($allowedEinrichts) )
AND ( cf.fachbereich_id IS NULL OR cf.fachbereich_id IN ($allowedFachbereiche) )
GROUP BY
c.id
ORDER BY
kcl.sorting ASC
";
$resSubs = mysqli_query($GLOBALS['mysql_con'], $sqlSubs);
if ($resSubs && mysqli_num_rows($resSubs) > 0) {
while ($sub = mysqli_fetch_assoc($resSubs)) {
$route = isset($_GET['route']) ? $_GET['route'] : '';
$new_route = $route ? $route . ',' . $category['id'] : $category['id'];
$currentURL = urlencode($_SERVER['REQUEST_URI']);
echo "<a class='post_wiki category_post' href=\"?detail={$sub['id']}&redirectURL={$currentURL}&route={$new_route}\">";
echo "<div class='subcategory'>";
$imgSrc = !empty($sub['image']) ? '/userdata/' . htmlspecialchars($sub['image']) : $placeholderCategory;
echo "<img src='" . $imgSrc . "' alt='Subkategorie Bild'>";
echo "<div class='card_title'>" . htmlspecialchars($sub['title'] ?: "Keine Übersetzung vorhanden") . "</div>";
if (!empty($sub['description'])) {
echo "<div class='card_description'>" . htmlspecialchars($sub['description']) . "</div>";
}
echo "</div>";
echo "</a>";
}
}
/**
* Beiträge, die der ausgewählten Kategorie zugeordnet sind, werden ebenfalls per LEFT JOIN
* gefiltert, sodass in den Bereichen (Abteilung, Mandant, Rolle) entweder kein Eintrag vorliegt
* oder der Eintrag mit den erlaubten Werten übereinstimmt.
*/
// Für Betriebsrat: nur Beiträge zeigen, die mindestens einer freigegebenen Kategorie zugeordnet sind
$brCategoryFilter = '';
if ($_kcBrOnly) {
$brCatIds = get_all_betriebsrat_accessible_category_ids();
if (empty($brCatIds)) {
$brCategoryFilter = 'AND 1=0'; // kein Zugriff
} else {
$brCatIdList = implode(',', $brCatIds);
$brCategoryFilter = "AND EXISTS (
SELECT 1 FROM knowledgecenter_category_post kcp_br
WHERE kcp_br.post_id = kp.id AND kcp_br.category_id IN ($brCatIdList)
)";
}
}
$sqlPosts = "
SELECT
kp.id,
kp.modified_at,
pv.title,
pv.image,
pv.link,
pv.link_label,
pv.version_number
FROM
knowledgecenter_category_post AS kcp
JOIN
knowledgecenter_posts AS kp ON kcp.post_id = kp.id
LEFT JOIN
knowledgecenter_post_versions AS pv ON kp.id = pv.post_id
AND pv.language_id = $languageId
AND pv.state = 1
AND (pv.valid_from IS NULL OR pv.valid_from <= NOW())
AND (pv.valid_until IS NULL OR pv.valid_until >= NOW())
AND pv.version_number = (
SELECT MAX(v2.version_number)
FROM knowledgecenter_post_versions AS v2
WHERE v2.post_id = kp.id
AND v2.language_id = $languageId
AND v2.state = 1
AND (v2.valid_from IS NULL OR v2.valid_from <= NOW())
AND (v2.valid_until IS NULL OR v2.valid_until >= NOW())
)
LEFT JOIN
knowledgecenter_post_department AS pd ON kp.id = pd.post_id
LEFT JOIN
knowledgecenter_post_mandant AS pm ON kp.id = pm.post_id
LEFT JOIN
knowledgecenter_post_role AS pr ON kp.id = pr.post_id
LEFT JOIN
knowledgecenter_post_einricht AS pe ON kp.id = pe.post_id
LEFT JOIN
knowledgecenter_post_fachbereich AS pfb ON kp.id = pfb.post_id
WHERE
kcp.category_id = $categoryId
AND pv.post_id IS NOT NULL
AND ( pd.department_id IS NULL OR pd.department_id IN ($allowedDepartments) )
AND ( pm.mandant_id IS NULL OR pm.mandant_id IN ($allowedMandants) )
AND ( pr.role_id IS NULL OR pr.role_id IN ($allowedRoles) )
AND ( pe.einricht_id IS NULL OR pe.einricht_id IN ($allowedEinrichts) )
AND ( pfb.fachbereich_id IS NULL OR pfb.fachbereich_id IN ($allowedFachbereiche) )
$brCategoryFilter
GROUP BY
kp.id
ORDER BY
$orderBy $orderDir
";
$resPosts = mysqli_query($GLOBALS['mysql_con'], $sqlPosts);
if ($resPosts && mysqli_num_rows($resPosts) > 0) {
$fmt = new IntlDateFormatter('de_DE', IntlDateFormatter::LONG, IntlDateFormatter::SHORT);
while ($post = mysqli_fetch_assoc($resPosts)) {
$route = isset($_GET['route']) ? $_GET['route'] : '';
$new_route = $route ? $route . ',' . $category['id'] : $category['id'];
$redirectURL = urlencode($_SERVER['REQUEST_URI']);
$detailHref = "?action=Post&id={$post['id']}&language={$languageCode}&redirectURL={$redirectURL}&route={$new_route}";
$imgSrc = !empty($post['image']) ? '/userdata/' . htmlspecialchars($post['image']) : $placeholderPost;
$titleText = htmlspecialchars($post['title'] ?: "Kein Titel vorhanden");
// Optionaler externer Link aus der Version
$linkRaw = trim((string) ($post['link'] ?? ''));
$linkLabelRaw = trim((string) ($post['link_label'] ?? ''));
$linkHtml = '';
if ($linkRaw !== '') {
$hrefForCheck = $linkRaw;
if (!preg_match('~^(?:https?:)?//|mailto:|/|#~i', $hrefForCheck)) {
$hrefForCheck = 'https://' . $hrefForCheck;
}
$safeUrl = htmlspecialchars($hrefForCheck, ENT_QUOTES, 'UTF-8');
$safeLabel = htmlspecialchars($linkLabelRaw !== '' ? $linkLabelRaw : $linkRaw, ENT_QUOTES, 'UTF-8');
$linkHtml = " <a class='post_ext_link' href='" . $safeUrl . "' target='_blank' rel='noopener noreferrer'>" . $safeLabel . "</a>";
}
echo "<div class='post_wiki'>";
echo " <a class='post_thumb' href='" . $detailHref . "'>";
echo " <img src='" . $imgSrc . "' alt='Beitrags Bild'>";
echo " </a>";
echo " <div class='card_header'>";
echo " <a class='card_title' href='" . $detailHref . "'>" . $titleText . "</a>" . $linkHtml;
echo " </div>";
echo "</div>";
}
}
echo "</div>"; // Ende des Grid-Containers für Detailansicht
} else {
echo "<div class='categories_grid'>";
// Übersicht aller Top-Level Kategorien (nicht als Unterkategorie verlinkt)
// mit LEFT JOIN zur Filterung anhand der User-Berechtigungen.
$sql = "
SELECT
c.id,
t.title,
t.description,
t.image
FROM
knowledgecenter_categories_update AS c
LEFT JOIN
knowledgecenter_category_translations AS t ON c.id = t.category_id AND t.language_id = $languageId
LEFT JOIN
knowledgecenter_category_department AS cd ON c.id = cd.category_id
LEFT JOIN
knowledgecenter_category_mandant AS cm ON c.id = cm.category_id
LEFT JOIN
knowledgecenter_category_role AS cr ON c.id = cr.category_id
LEFT JOIN
knowledgecenter_category_einricht AS ce ON c.id = ce.category_id
LEFT JOIN
knowledgecenter_category_fachbereich AS cf ON c.id = cf.category_id
WHERE
c.id NOT IN (SELECT child_category_id FROM knowledgecenter_category_links)
AND c.state = 1
AND ( cd.department_id IS NULL OR cd.department_id IN ($allowedDepartments) )
AND ( cm.mandant_id IS NULL OR cm.mandant_id IN ($allowedMandants) )
AND ( cr.role_id IS NULL OR cr.role_id IN ($allowedRoles) )
AND ( ce.einricht_id IS NULL OR ce.einricht_id IN ($allowedEinrichts) )
AND ( cf.fachbereich_id IS NULL OR cf.fachbereich_id IN ($allowedFachbereiche) )
GROUP BY
c.id
ORDER BY
c.sorting ASC
";
// var_dump($sql);
$res = mysqli_query($GLOBALS['mysql_con'], $sql);
if ($res && mysqli_num_rows($res) > 0) {
while ($row = mysqli_fetch_assoc($res)) {
$route = isset($_GET['route']) ? $_GET['route'] : '';
$new_route = $route ? $route . ',' . $row['id'] : $row['id'];
echo "<a class='post_wiki category_post' href=\"?detail={$row['id']}&route={$new_route}\">";
echo "<div class='category'>";
$imgSrc = !empty($row['image']) ? '/userdata/' . htmlspecialchars($row['image']) : $placeholderCategory;
echo "<img src='" . $imgSrc . "' alt='Kategorie Bild'>";
echo "<div class='card_title'>" . htmlspecialchars($row['title'] ?: "Keine Übersetzung vorhanden") . "</div>";
if (!empty($row['description'])) {
echo "<div class='card_description'>" . htmlspecialchars($row['description']) . "</div>";
}
echo "</div>";
echo "</a>";
}
}
echo "</div>";
}
?>
</form>
<script>
// Hilfsfunktion, um den Query-Parameter in einer URL zu aktualisieren
function updateQueryStringParameter(uri, key, value) {
var re = new RegExp("([?&])" + key + "=.*?(&|$)", "i");
var separator = uri.indexOf('?') !== -1 ? "&" : "?";
if (uri.match(re)) {
return uri.replace(re, '$1' + key + "=" + value + '$2');
} else {
return uri + separator + key + "=" + value;
}
}
// Funktion zum Durchführen der Suche
function performSearch(query, page = 1, append = false) {
if (!append) {
var newUrl = updateQueryStringParameter(window.location.href, 'query', encodeURIComponent(query));
window.history.pushState({ path: newUrl }, '', newUrl);
}
$.ajax({
url: '/module/knowledgecenter_update/Ajax/Ajax.php',
type: 'POST',
data: {
action: 'search_posts',
query: query,
language: '<?= $languageId ?>',
main_contact_id: '<?= $GLOBALS["main_contact"]["id"] ?>',
languageCode: '<?= $languageCode ?>',
page: page
},
dataType: 'json',
beforeSend: function () {
$('#overlayLoader').show();
},
complete: function () {
$('#overlayLoader').hide();
},
success: function (response) {
if (response.success) {
if (append) {
$('#searchResults').append(response.html);
} else {
$('#searchResults').html(response.html);
}
$('.categories_grid, .category_page_titlebar').hide();
} else {
$('#searchResults').html('<p>Fehler bei der Suche</p>');
$('.categories_grid, .category_page_titlebar').show();
}
},
error: function () {
$('#searchResults').html('<p>Verbindungsfehler</p>');
// $('.categories_grid, .category_page_titlebar').show();
}
});
}
$(document).ready(function () {
// Setze den Seitentitel in <h1 id="intranetTitle"> basierend auf ?detail und vorhandenem Kategorienamen
(function setIntranetTitle() {
var h1 = document.getElementById('intranetTitle');
console.log(h1);
if (!h1) return;
var params = new URLSearchParams(window.location.search);
var hasDetail = params.has('detail');
var catTitle = (typeof window.kcCategoryTitle !== 'undefined' && window.kcCategoryTitle) ? String(window.kcCategoryTitle) : null;
if (hasDetail && catTitle) {
h1.textContent = catTitle;
} else {
h1.textContent = 'Wiki';
}
})();
const $searchInput = $('#searchInput');
const $resultContainer = $('#searchResults');
const $categoriesGrid = $('.categories_grid, .category_page_titlebar');
// Überprüfe, ob ein Suchparameter in der URL vorhanden ist:
const urlParams = new URLSearchParams(window.location.search);
const queryParam = urlParams.get('query');
if (queryParam && queryParam.length >= 2) {
$searchInput.val(queryParam);
performSearch(queryParam);
}
let debounceTimer;
$searchInput.on('input', function () {
clearTimeout(debounceTimer);
const query = $.trim($searchInput.val());
if (query.length < 2) {
$resultContainer.empty();
$categoriesGrid.show();
const newUrl = updateQueryStringParameter(window.location.href, 'query', '');
window.history.pushState({ path: newUrl }, '', newUrl);
return;
}
debounceTimer = setTimeout(function () {
performSearch(query);
}, 300);
});
$(document).on('click', '#loadMoreBtn', function () {
var nextPage = $(this).data('page');
var query = $.trim($searchInput.val());
$(this).remove();
performSearch(query, nextPage, true);
});
// Definiere die beiden SVG-Icons als HTML-Strings
const listIconHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-view-list" viewBox="0 0 16 16">
<path d="M3 4.5h10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2m0 1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zM1 2a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 2m0 12a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 14"/>
</svg>`;
const gridIconHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-grid" viewBox="0 0 16 16">
<path d="M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5v3A1.5 1.5 0 0 1 5.5 7h-3A1.5 1.5 0 0 1 1 5.5zM2.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5zm6.5.5A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7h-3A1.5 1.5 0 0 1 9 5.5zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5zM1 10.5A1.5 1.5 0 0 1 2.5 9h3A1.5 1.5 0 0 1 7 10.5v3A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5zm6.5.5A1.5 1.5 0 0 1 10.5 9h3a1.5 1.5 0 0 1 1.5 1.5v3a1.5 1.5 0 0 1-1.5 1.5h-3A1.5 1.5 0 0 1 9 13.5zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5z"/>
</svg>`;
// Beim Laden der Seite prüfen, ob ein Modus im localStorage gespeichert wurde
const savedMode = localStorage.getItem("categoryGridView");
if (savedMode === "list") {
$(".categories_grid").addClass("list_view");
$("#changeCategoryGridView").addClass("active").html(listIconHTML);
} else {
$(".categories_grid").removeClass("list_view");
$("#changeCategoryGridView").removeClass("active").html(gridIconHTML);
}
// Klick-Event für den Toggle-Button
$("#changeCategoryGridView").on("click", function () {
$(".categories_grid").toggleClass("list_view");
$(this).toggleClass("active");
// Je nach Zustand in localStorage speichern und das entsprechende Icon setzen:
if ($(".categories_grid").hasClass("list_view")) {
localStorage.setItem("categoryGridView", "list");
$(this).html(listIconHTML);
} else {
localStorage.setItem("categoryGridView", "grid");
$(this).html(gridIconHTML);
}
});
window.onpopstate = function (event) {
const params = new URLSearchParams(window.location.search);
const q = params.get('query');
if (q && q.length >= 2) {
$searchInput.val(q);
performSearch(q);
} else {
$searchInput.val('');
$resultContainer.empty();
$categoriesGrid.show();
}
};
});
</script>

View File

@@ -0,0 +1,274 @@
<?php
require_once(__DIR__ . '/../Middleware/Middleware.php');
// Sichtbarkeitsfilter auf Basis der Benutzerberechtigungen (Mandant, Abteilung, Rolle)
$allowedMandants = $GLOBALS['allowedMandants'];
$allowedDepartments = $GLOBALS['allowedDepartments'];
$allowedRoles = $GLOBALS['allowedRoles'];
// Sprache ermitteln (Fallback: 1)
$languageId = (int) ($GLOBALS['language']['id'] ?? 1);
$languageCode = (string) ($GLOBALS['language']['code'] ?? 'de');
$siteCode = (string) ($GLOBALS['site']['code'] ?? 'intranet');
// Limit für Anzahl der Beiträge (fest): 100
$limit = 100;
// Beiträge laden: jüngste Posts der letzten 3 Wochen, unabhängig von Kategorie.
// Wir ziehen die aktuellste Version pro Sprache.
$fallbackLanguageId = 1;
$sql = "
SELECT
kp.id,
kp.modified_at,
kp.created_at,
pv.title,
pv.image,
pv.teaser,
-- Kategorien (Title + Color) in Wunsch-Sprache, sonst Fallback=1
GROUP_CONCAT(
DISTINCT CONCAT(
COALESCE(kct_lang.title, kct_fb.title),
'|',
COALESCE(kcu.color_hex, '#3B82F6')
)
ORDER BY COALESCE(kct_lang.title, kct_fb.title)
SEPARATOR '||'
) AS category_tags
FROM knowledgecenter_posts AS kp
LEFT JOIN knowledgecenter_post_versions AS pv
ON pv.post_id = kp.id
AND pv.language_id = ?
AND pv.state = 1
AND (pv.valid_from IS NULL OR pv.valid_from <= NOW())
AND (pv.valid_until IS NULL OR pv.valid_until >= NOW())
AND pv.version_number = (
SELECT MAX(v2.version_number)
FROM knowledgecenter_post_versions v2
WHERE v2.post_id = kp.id
AND v2.language_id = ?
AND v2.state = 1
AND (v2.valid_from IS NULL OR v2.valid_from <= NOW())
AND (v2.valid_until IS NULL OR v2.valid_until >= NOW())
)
-- Kategorie-Verknüpfung Post -> Kategorie
LEFT JOIN knowledgecenter_category_post AS kcp
ON kcp.post_id = kp.id
-- Kategorie-Stammdaten (Farbe)
LEFT JOIN knowledgecenter_categories_update AS kcu
ON kcu.id = kcp.category_id
-- Kategorie Titel in Wunsch-Sprache
LEFT JOIN knowledgecenter_category_translations AS kct_lang
ON kct_lang.category_id = kcp.category_id
AND kct_lang.language_id = ?
-- Fallback-Sprache (1)
LEFT JOIN knowledgecenter_category_translations AS kct_fb
ON kct_fb.category_id = kcp.category_id
AND kct_fb.language_id = ?
-- Sichtbarkeiten (nur Einschränkungen existieren -> filtern, sonst sichtbar)
LEFT JOIN knowledgecenter_post_department AS pd ON kp.id = pd.post_id
LEFT JOIN knowledgecenter_post_mandant AS pm ON kp.id = pm.post_id
LEFT JOIN knowledgecenter_post_role AS pr ON kp.id = pr.post_id
LEFT JOIN knowledgecenter_post_einricht AS pe ON kp.id = pe.post_id
LEFT JOIN knowledgecenter_post_fachbereich AS pfb ON kp.id = pfb.post_id
WHERE
pv.post_id IS NOT NULL
AND pv.modified_at >= DATE_SUB(NOW(), INTERVAL 3 WEEK)
AND (pd.department_id IS NULL OR pd.department_id IN ($allowedDepartments))
AND (pm.mandant_id IS NULL OR pm.mandant_id IN ($allowedMandants))
AND (pr.role_id IS NULL OR pr.role_id IN ($allowedRoles))
AND (pe.einricht_id IS NULL OR pe.einricht_id IN ($allowedEinrichts))
AND (pfb.fachbereich_id IS NULL OR pfb.fachbereich_id IN ($allowedFachbereiche))
GROUP BY kp.id
ORDER BY pv.modified_at DESC
LIMIT ?
";
$stmt = mysqli_prepare($GLOBALS['mysql_con'], $sql);
mysqli_stmt_bind_param($stmt, 'iiiii', $languageId, $languageId, $languageId, $fallbackLanguageId, $limit);
mysqli_stmt_execute($stmt);
$res = mysqli_stmt_get_result($stmt);
$posts = mysqli_fetch_all($res, MYSQLI_ASSOC);
// if (!$posts) {
// return;
// }
?>
<style>
.wiki_preview {
float: left;
width: 100%;
}
.wiki_preview_grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
/* .wiki_preview_grid img {
aspect-ratio: 4 / 3;
border-bottom: 1px solid var(--border);
} */
.wiki_preview_grid img {
border-bottom: 1px solid var(--border);
object-fit: contain;
background: var(--bg-white);
height: 186px ! Important;
}
.card_title {
padding: 1rem;
padding-top: 0;
padding-bottom: 3px;
font-weight: bold;
font-size: 16px;
}
.card_date {
padding: 0 1rem;
padding-top: 1rem;
font-size: 12px;
color: var(--font-light, #666);
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 5px;
}
.card_teaser {
padding: 0 1rem;
-webkit-line-clamp: 2;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
margin-bottom: 1rem;
font-size: 13px;
opacity: .8;
}
.wiki_preview_grid a {
background: var(--bg-white);
border-radius: var(--br);
overflow: hidden;
border: 1px solid var(--border);
}
span.card_category {
padding: 2px 7px;
color: white;
border-radius: 5px;
}
span.card_category_wrap {
display: inline-flex;
gap: 5px;
}
.post_wiki:not(:has(.card_teaser)) {
padding-block-end: 1rem;
}
</style>
<div hidden>
<?php
// var_dump($GLOBALS['allowedMandants']);
// var_dump($allowedDepartments);
// var_dump($allowedRoles);
?>
</div>
<div class="wiki_preview">
<div class="wiki_preview_grid">
<?php foreach ($posts as $p): ?>
<?php
$title = $p['title'] ?: ('Post #' . $p['id']);
$imgSrc = !empty($p['image'])
? '/userdata/' . htmlspecialchars($p['image'], ENT_QUOTES, 'UTF-8')
: '/userdata/knowledgecenter_update/platzhalter.jpg';
$teaser = isset($p['teaser']) ? trim((string) $p['teaser']) : '';
$teaserEsc = htmlspecialchars($teaser, ENT_QUOTES, 'UTF-8');
// Erstelldatum (Deutsch, z.B. 01.12.2025)
$createdDateDisplay = '';
if (!empty($p['created_at'])) {
try {
$createdDt = new DateTime($p['created_at']);
$createdDateDisplay = $createdDt->format('d.m.Y');
} catch (Exception $e) {
$createdDateDisplay = '';
}
}
$categoryTagsRaw = trim((string) ($p['category_tags'] ?? ''));
$categoryTags = [];
if ($categoryTagsRaw !== '') {
$items = explode('||', $categoryTagsRaw);
foreach ($items as $item) {
$parts = explode('|', $item, 2);
$catTitle = trim($parts[0] ?? '');
$catColor = trim($parts[1] ?? '');
if ($catTitle === '') {
continue;
}
// Fallback Farbe falls leer / ungültig
// if (!preg_match('/^#[0-9A-Fa-f]{8}$/', $catColor)) {
// $catColor = '#3B82F6FF';
// }
$categoryTags[] = [
'title' => $catTitle,
'color' => $catColor
];
}
}
// redirectURL = aktuelle Seite, damit man zurückkommt
$redirectURL = urlencode($_SERVER['REQUEST_URI']);
?>
<a
href="/<?= htmlspecialchars($siteCode, ENT_QUOTES, 'UTF-8') ?>/<?= htmlspecialchars($languageCode, ENT_QUOTES, 'UTF-8') ?>/wiki/?action=Post&id=<?= urlencode((string) $p['id']) ?>&language=<?= htmlspecialchars($languageCode, ENT_QUOTES, 'UTF-8') ?>&redirectURL=<?= urlencode($_SERVER['REQUEST_URI']) ?>">
<div class="post_wiki">
<img src="<?= $imgSrc ?>" alt="Beitrags Bild">
<div class="card_date">
<?= htmlspecialchars($createdDateDisplay, ENT_QUOTES, 'UTF-8') ?>
<?php if (!empty($categoryTags)): ?>
<span class="card_category_wrap">
<?php foreach ($categoryTags as $cat): ?>
<?php
$titleCat = (string) ($cat['title'] ?? '');
$max = 15;
$short = (mb_strlen($titleCat, 'UTF-8') > $max)
? mb_substr($titleCat, 0, $max - 1, 'UTF-8') . '…'
: $titleCat;
?>
<span class="card_category"
style="background: <?= htmlspecialchars($cat['color'] ?? '', ENT_QUOTES, 'UTF-8') ?>;">
<?= htmlspecialchars($short, ENT_QUOTES, 'UTF-8') ?>
</span>
<?php endforeach; ?>
</span>
<?php endif; ?>
</div>
<div class="card_title"><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></div>
<?php if ($teaser !== ''): ?>
<div class="card_teaser"><?= $teaserEsc ?></div>
<?php endif; ?>
</div>
</a>
<?php endforeach; ?>
</div>
</div>

View File

@@ -0,0 +1,413 @@
<?php
/**
* category_posts_widget.php
*
* Minimal-Widget: Zeigt alle Beiträge (Posts) einer hinterlegten Wiki-Kategorie an.
* Erwartet: $sitepart['wiki_category_id'] ODER GET-Parameter `category_id`.
* Sonst passiert nichts.
*/
// Sichtbarkeitsfilter auf Basis der Benutzerberechtigungen (Mandant, Abteilung, Rolle)
require_once(__DIR__ . '/../Middleware/Middleware.php');
// Sichtbarkeitsfilter auf Basis der Benutzerberechtigungen (Mandant, Abteilung, Rolle)
$allowedMandants = $GLOBALS['allowedMandants'];
$allowedDepartments = $GLOBALS['allowedDepartments'];
$allowedRoles = $GLOBALS['allowedRoles'];
// Sprache ermitteln (Fallback: 1)
$languageId = (int) ($GLOBALS['language']['id'] ?? 1);
$languageCode = (string) ($GLOBALS['language']['code'] ?? 'de');
$siteCode = (string) ($GLOBALS['site']['code'] ?? 'intranet');
// Kategorie-ID fest auf 49 gesetzt (statt aus Sitepart/GET)
$categoryId = 49;
// Beiträge der Kategorie laden (neueste zuerst). Wir ziehen die aktuellste Version pro Sprache.
$sql = "
SELECT
kp.id,
kp.modified_at,
pv.title,
pv.image,
pv.teaser,
pv.background_color AS version_background_color,
pv.text_color AS version_text_color,
kp.background_color AS post_background_color,
kp.text_color AS post_text_color
FROM knowledgecenter_category_post AS kcp
JOIN knowledgecenter_posts AS kp
ON kcp.post_id = kp.id
LEFT JOIN knowledgecenter_post_versions AS pv
ON pv.post_id = kp.id
AND pv.language_id = ?
AND pv.state = 1
AND (pv.valid_from IS NULL OR pv.valid_from <= NOW())
AND (pv.valid_until IS NULL OR pv.valid_until >= NOW())
AND pv.version_number = (
SELECT MAX(v2.version_number)
FROM knowledgecenter_post_versions v2
WHERE v2.post_id = kp.id
AND v2.language_id = ?
AND v2.state = 1
AND (v2.valid_from IS NULL OR v2.valid_from <= NOW())
AND (v2.valid_until IS NULL OR v2.valid_until >= NOW())
)
LEFT JOIN knowledgecenter_post_department AS pd ON kp.id = pd.post_id
LEFT JOIN knowledgecenter_post_mandant AS pm ON kp.id = pm.post_id
LEFT JOIN knowledgecenter_post_role AS pr ON kp.id = pr.post_id
LEFT JOIN knowledgecenter_post_einricht AS pe ON kp.id = pe.post_id
LEFT JOIN knowledgecenter_post_fachbereich AS pfb ON kp.id = pfb.post_id
WHERE kcp.category_id = ?
AND pv.post_id IS NOT NULL
AND (pd.department_id IS NULL OR pd.department_id IN ($allowedDepartments))
AND (pm.mandant_id IS NULL OR pm.mandant_id IN ($allowedMandants))
AND (pr.role_id IS NULL OR pr.role_id IN ($allowedRoles))
AND (pe.einricht_id IS NULL OR pe.einricht_id IN ($allowedEinrichts))
AND (pfb.fachbereich_id IS NULL OR pfb.fachbereich_id IN ($allowedFachbereiche))
GROUP BY kp.id
ORDER BY kp.created_at DESC
";
$stmt = mysqli_prepare($GLOBALS['mysql_con'], $sql);
mysqli_stmt_bind_param($stmt, 'iii', $languageId, $languageId, $categoryId);
mysqli_stmt_execute($stmt);
$res = mysqli_stmt_get_result($stmt);
$posts = mysqli_fetch_all($res, MYSQLI_ASSOC);
$postCount = is_array($posts) ? count($posts) : 0;
$anncInstance = 'annc_' . uniqid();
$normalizeColor = function ($value) {
$value = trim((string) $value);
if (preg_match('/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/', $value)) {
return strtoupper($value);
}
return '';
};
$resolvePostColor = function ($row, $versionKey, $postKey) use ($normalizeColor) {
// Prioritaet: zuerst Post-Einstellung (Settings-Tab), dann Versionsfarbe.
$postColor = $normalizeColor($row[$postKey] ?? '');
if ($postColor !== '') {
return $postColor;
}
return $normalizeColor($row[$versionKey] ?? '');
};
$sqlSubs = "
SELECT
c.id,
t.title,
t.image
FROM
knowledgecenter_category_links AS kcl
JOIN
knowledgecenter_categories_update AS c ON kcl.child_category_id = c.id
LEFT JOIN
knowledgecenter_category_translations AS t ON c.id = t.category_id AND t.language_id = $languageId
LEFT JOIN
knowledgecenter_category_department AS cd ON c.id = cd.category_id
LEFT JOIN
knowledgecenter_category_mandant AS cm ON c.id = cm.category_id
LEFT JOIN
knowledgecenter_category_role AS cr ON c.id = cr.category_id
LEFT JOIN
knowledgecenter_category_einricht AS ce ON c.id = ce.category_id
LEFT JOIN
knowledgecenter_category_fachbereich AS cf ON c.id = cf.category_id
WHERE
kcl.parent_category_id = $categoryId
AND c.state = 1
AND (cd.department_id IS NULL OR cd.department_id IN ($allowedDepartments))
AND (cm.mandant_id IS NULL OR cm.mandant_id IN ($allowedMandants))
AND (cr.role_id IS NULL OR cr.role_id IN ($allowedRoles))
AND (ce.einricht_id IS NULL OR ce.einricht_id IN ($allowedEinrichts))
AND (cf.fachbereich_id IS NULL OR cf.fachbereich_id IN ($allowedFachbereiche))
GROUP BY
c.id
ORDER BY
kcl.sorting ASC
";
$placeholderCategory = '/userdata/intranet/mandant/platzhalter_kategorie.png';
$resSubs = mysqli_query($GLOBALS['mysql_con'], $sqlSubs);
$subCount = ($resSubs && mysqli_num_rows($resSubs) > 0) ? mysqli_num_rows($resSubs) : 0;
// Wenn weder Subkategorien noch Posts vorhanden sind, Widget gar nicht rendern
if ($postCount === 0 && $subCount === 0) {
return;
}
?>
<style>
.announcement-list {
color: white;
float: left;
width: 100%;
background: var(--clr-mandant);
border-radius: var(--br);
overflow: hidden;
position: relative;
border: 1px solid var(--border);
}
.announcement-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.card_title {
padding: 1rem;
padding-bottom: 3px;
font-weight: bold;
font-size: 18px;
}
.announcement-item {
padding: 10px;
text-align: center;
}
.announcement {
display: block;
color: inherit;
}
.announcement-grid a {
background: var(--bg-white);
border-radius: var(--br);
overflow: hidden;
}
.announcement-single {
width: 100%;
}
.announcement-slider {
position: relative;
overflow: hidden;
width: 100%;
--annc-progress: 0%;
}
.announcement-slider::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
height: 3px;
width: var(--annc-progress);
background: white;
transition: width 50ms linear;
pointer-events: none;
}
.announcement-track {
display: flex;
transition: transform 0.3s ease;
will-change: transform;
}
.announcement-slide {
min-width: 100%;
box-sizing: border-box;
flex-shrink: 0;
}
.announcement-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
border: none;
background: rgb(255 255 255 / 30%);
width: 1rem;
height: 1rem;
border-radius: 999px;
display: grid;
place-items: center;
padding: 0px;
cursor: pointer;
line-height: 1;
color: white;
font-size: 11px;
}
.announcement-nav.prev {
left: .5rem;
}
.announcement-nav.next {
right: .5rem;
}
</style>
<div class="announcement-list">
<div class="announcement-grid">
<?php
if ($resSubs && $subCount > 0) {
// Zeiger steht nach mysqli_num_rows() noch am Anfang; wir können direkt iterieren
while ($sub = mysqli_fetch_assoc($resSubs)) {
$redirectURL = urlencode($_SERVER['REQUEST_URI']);
$url = "/" . htmlspecialchars($siteCode, ENT_QUOTES, 'UTF-8') . "/" . htmlspecialchars($languageCode, ENT_QUOTES, 'UTF-8') . "/wiki/?detail=" . $sub['id'] . "&redirectURL=" . $redirectURL;
echo "<a href=\"{$url}\">";
echo "<div class='subcategory'>";
$imgSrc = !empty($sub['image']) ? '/userdata/' . htmlspecialchars($sub['image']) : $placeholderCategory;
echo "<img src='" . $imgSrc . "' alt='Subkategorie Bild'>";
echo "<div class='card_title'>" . htmlspecialchars($sub['title'] ?: "Keine Übersetzung vorhanden") . "</div>";
echo "</div>";
echo "</a>";
}
}
?>
</div>
<?php if ($postCount <= 1): ?>
<div class="announcement-single" id="<?= $anncInstance ?>" tabindex="0">
<?php foreach ($posts as $p): ?>
<?php $title = $p['title'] ?: ('Post #' . $p['id']);
$redirectURL = urlencode($_SERVER['REQUEST_URI']);
$bgColor = $resolvePostColor($p, 'version_background_color', 'post_background_color');
$textColor = $resolvePostColor($p, 'version_text_color', 'post_text_color');
$styleParts = [];
if ($bgColor !== '') {
$styleParts[] = "background-color: {$bgColor}";
}
if ($textColor !== '') {
$styleParts[] = "color: {$textColor}";
}
$announcementStyle = implode('; ', $styleParts);
?>
<a class="announcement"
href="/<?= htmlspecialchars($siteCode, ENT_QUOTES, 'UTF-8') ?>/<?= htmlspecialchars($languageCode, ENT_QUOTES, 'UTF-8') ?>/wiki/?action=Post&id=<?= urlencode((string) $p['id']) ?>&language=<?= htmlspecialchars($languageCode, ENT_QUOTES, 'UTF-8') ?>&redirectURL=<?= $redirectURL ?>">
<div class="announcement-item" <?= $announcementStyle !== '' ? 'style="' . htmlspecialchars($announcementStyle, ENT_QUOTES, 'UTF-8') . '"' : '' ?>>
<div class="announcement-title"><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></div>
</div>
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="announcement-slider" id="<?= $anncInstance ?>" tabindex="0">
<div class="announcement-track" data-track>
<?php foreach ($posts as $p): ?>
<?php $title = $p['title'] ?: ('Post #' . $p['id']);
$redirectURL = urlencode($_SERVER['REQUEST_URI']);
$bgColor = $resolvePostColor($p, 'version_background_color', 'post_background_color');
$textColor = $resolvePostColor($p, 'version_text_color', 'post_text_color');
$styleParts = [];
if ($bgColor !== '') {
$styleParts[] = "background-color: {$bgColor}";
}
if ($textColor !== '') {
$styleParts[] = "color: {$textColor}";
}
$announcementStyle = implode('; ', $styleParts);
?>
<div class="announcement-slide" data-slide>
<a class="announcement"
href="/<?= htmlspecialchars($siteCode, ENT_QUOTES, 'UTF-8') ?>/<?= htmlspecialchars($languageCode, ENT_QUOTES, 'UTF-8') ?>/wiki/?action=Post&id=<?= urlencode((string) $p['id']) ?>&language=<?= htmlspecialchars($languageCode, ENT_QUOTES, 'UTF-8') ?>&redirectURL=<?= $redirectURL ?>">
<div class="announcement-item" <?= $announcementStyle !== '' ? 'style="' . htmlspecialchars($announcementStyle, ENT_QUOTES, 'UTF-8') . '"' : '' ?>>
<div class="announcement-title"><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></div>
</div>
</a>
</div>
<?php endforeach; ?>
</div>
<button class="announcement-nav prev" type="button" aria-label="Zurück" data-prev></button>
<button class="announcement-nav next" type="button" aria-label="Weiter" data-next></button>
</div>
<script>
(function () {
var root = document.getElementById('<?= $anncInstance ?>');
if (!root) return;
var track = root.querySelector('[data-track]');
var slides = Array.prototype.slice.call(root.querySelectorAll('[data-slide]'));
var prevBtn = root.querySelector('[data-prev]');
var nextBtn = root.querySelector('[data-next]');
var idx = 0;
function update() {
track.style.transform = 'translateX(' + (-idx * 100) + '%)';
}
// --- Auto-Slide (500ms) + Progressbar ---
var intervalMs = 10000; // gewünschtes Intervall
var tickMs = 50; // Schrittweite für die Progressbar
var progress = 0; // 0..100
var timer = null;
function setProgress(pct) {
root.style.setProperty('--annc-progress', Math.max(0, Math.min(100, pct)) + '%');
}
function resetProgress() {
progress = 0;
setProgress(0);
}
function step() {
progress += (tickMs / intervalMs) * 100; // z. B. 10% pro 50ms bei 500ms Gesamtzeit
if (progress >= 100) {
progress = 0;
idx = (idx + 1) % slides.length;
update();
}
setProgress(progress);
}
function startAuto() {
if (timer) return;
timer = setInterval(step, tickMs);
}
function stopAuto() {
if (!timer) return;
clearInterval(timer);
timer = null;
}
// Buttons
if (prevBtn) prevBtn.addEventListener('click', function () {
idx = (idx - 1 + slides.length) % slides.length;
update();
resetProgress();
});
if (nextBtn) nextBtn.addEventListener('click', function () {
idx = (idx + 1) % slides.length;
update();
resetProgress();
});
// Touch/swipe
var startX = 0, deltaX = 0, dragging = false;
track.addEventListener('touchstart', function (e) { startX = e.touches[0].clientX; dragging = true; }, { passive: true });
track.addEventListener('touchmove', function (e) { if (!dragging) return; deltaX = e.touches[0].clientX - startX; }, { passive: true });
track.addEventListener('touchend', function () {
if (!dragging) return;
if (Math.abs(deltaX) > 50) {
if (deltaX < 0) { idx = (idx + 1) % slides.length; }
else { idx = (idx - 1 + slides.length) % slides.length; }
update();
resetProgress();
}
deltaX = 0; dragging = false;
});
// Keyboard
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { idx = (idx + 1) % slides.length; update(); resetProgress(); }
else if (e.key === 'ArrowLeft') { idx = (idx - 1 + slides.length) % slides.length; update(); resetProgress(); }
});
// Optional: pausieren bei Hover/Focus für bessere Klickbarkeit
root.addEventListener('mouseenter', stopAuto);
root.addEventListener('mouseleave', function () { resetProgress(); startAuto(); });
root.addEventListener('focusin', stopAuto);
root.addEventListener('focusout', function () { resetProgress(); startAuto(); });
// Initial
update();
setProgress(0);
if (slides.length > 1) startAuto();
})();
</script>
<?php endif; ?>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,318 @@
<?php
// if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['check_certificate_wiki'])) {
// $certId = (int) $_POST['check_certificate_wiki'];
// $userId = (int) $GLOBALS['main_contact']['id'];
// $currentDate = (new DateTime())->format('Y-m-d H:i:s');
// // prüfen, ob schon ein Eintrag existiert
// $sqlSubmit = "
// SELECT id
// FROM certificate_contact_submit
// WHERE certificate_id = {$certId}
// AND main_contact_id = {$userId}
// LIMIT 1
// ";
// $resSubmit = mysqli_query($GLOBALS['mysql_con'], $sqlSubmit);
// if ($row = mysqli_fetch_assoc($resSubmit)) {
// // UPDATE
// $sqlUp = "
// UPDATE certificate_contact_submit
// SET correct = 1,
// issue_date = '{$currentDate}'
// WHERE id = " . (int) $row['id'];
// mysqli_query($GLOBALS['mysql_con'], $sqlUp);
// } else {
// // INSERT
// $sqlIn = "
// INSERT INTO certificate_contact_submit
// (certificate_id, main_contact_id, issue_date, correct)
// VALUES
// ({$certId}, {$userId}, '{$currentDate}', 1)
// ";
// mysqli_query($GLOBALS['mysql_con'], $sqlIn);
// }
// echo "<script>
// // ersetzt die aktuelle Seite durch einen sauberen GET auf dieselbe URL
// window.location.replace(window.location.href);
// </script>";
// exit;
// }
// // fetch_and_process_emails();
// $certificate_type = $sitepart['learning_certificate_type'];
// $certificate_items = $sitepart['sitepart_items'];
// if (isset($_GET['contact'])) {
// return;
// }
// $query = "
// SELECT *
// FROM main_certificate
// LEFT JOIN certificate_contact
// ON certificate_contact.certificate_id = main_certificate.id
// WHERE main_contact_id = " . (int) $GLOBALS['main_contact']['id'] . "
// AND main_certificate.state = 1
// AND (main_certificate.validity_from <= NOW() OR main_certificate.validity_from IS NULL)
// AND active = 1
// ";
// $result = @mysqli_query($GLOBALS['mysql_con'], $query);
// $completedCertificates = [];
// $incompleteCertificates = [];
// while ($row = @mysqli_fetch_assoc($result)) {
// // statt $row['certificate_id'] immer main_certificate.id verwenden
// $certId = (int) $row['id'];
// $query_submit = "
// SELECT *
// FROM certificate_contact_submit
// WHERE certificate_id = {$certId}
// AND main_contact_id = " . (int) $GLOBALS['main_contact']['id'] . "
// ORDER BY id DESC
// LIMIT 1
// ";
// $result_submit = @mysqli_query($GLOBALS['mysql_con'], $query_submit);
// $row_submit = @mysqli_fetch_assoc($result_submit);
// $current_date = new DateTime();
// $issue_date = new DateTime($row_submit['issue_date'] ?? 'now');
// $validity_date = clone $issue_date;
// $validity_date->modify('+' . (int) $row['validity_period'] . ' months');
// if (($row_submit['correct'] ?? 0) == 1 && $validity_date >= $current_date) {
// $completedCertificates[] = [
// 'row' => $row,
// 'row_submit' => $row_submit,
// 'issue_date' => $issue_date,
// 'validity_date' => $validity_date
// ];
// } else {
// $incompleteCertificates[] = [
// 'row' => $row,
// 'row_submit' => $row_submit,
// 'issue_date' => $issue_date,
// 'validity_date' => $validity_date
// ];
// }
// }
// $siteParam = isset($_GET['site']) ? trim($_GET['site']) : '';
// $langCode = $GLOBALS['language']['code'] ?? '';
// function buildCertificateLink(array $row, string $siteParam, string $langCode): string
// {
// if (!empty($row['main_collection_id'])) {
// return get_link_to_navigation($row['main_collection_page_list_id'])
// . get_collection_rewrite("-", $row['main_collection_id']);
// }
// if (!empty($row['post_id'])) {
// return "/{$siteParam}/{$langCode}/wiki?action=Post&id=" . (int) $row['post_id'];
// }
// return "javascript:void(0)";
// }
?>
<div class="post-title">
<?php if ($isNew): ?>
<h1 id="displayTitle"><?= $translation->get("create_article") ?></h1>
<?php else: ?>
<h1 id="displayTitle"><?php echo htmlspecialchars($currentVersion['title']); ?></h1>
<?php endif; ?>
<div>
<?php if (!empty($postId) && $postId > 0 && ($canEditPost ?? false)): ?>
<span id="editBtn" class="post-action-btn grey" title="Aktuelle Version bearbeiten">✎</span>
<span id="createBtn" class="green" title="Neue Version erstellen">Neue Version erstellen +</span>
<?php endif; ?>
</div>
</div>
<div class="post-grid-container">
<div class="post-left-container">
<?php if (!empty($currentVersion['teaser'])): ?>
<div class="post-teaser" id="displayTeaser">
<p><?php echo nl2br(htmlspecialchars($currentVersion['teaser'])); ?></p>
</div>
<?php endif; ?>
<?php
if (!empty($post['preview_inline']) && $post['preview_inline'] == 1) {
$pdfQuery = "
SELECT *
FROM knowledgecenter_post_files
WHERE post_id = " . intval($postId) . "
AND LOWER(path) LIKE '%.pdf%'
LIMIT 1
";
$pdfResult = mysqli_query($GLOBALS['mysql_con'], $pdfQuery);
if ($pdfResult && mysqli_num_rows($pdfResult) > 0) {
$pdfFile = mysqli_fetch_assoc($pdfResult);
$pdfPath = htmlspecialchars($pdfFile['path']);
?>
<div class="post-preview-embed">
<object data="<?php echo $pdfPath; ?>" type="application/pdf" width="100%" height="1000px">
Ihr Browser unterstützt die Anzeige von PDFs nicht.
<a href="<?php echo $pdfPath; ?>">PDF herunterladen</a>
</object>
</div>
<?php
}
}
?>
<div class="post-content" id="displayContent">
<?php echo $currentVersion['content']; ?>
</div>
</div><!-- /.post-left-container -->
<div class="post-right-container">
<?php if (!empty($postId) && $postId > 0): ?>
<?php if (!empty($currentVersion['image'])): ?>
<?php
$imgSrc = '/userdata/' . htmlspecialchars($currentVersion['image']);
?>
<div class="post-aside-section">
<a data-fslightbox="gallery" href="<?= $imgSrc ?>">
<img id="displayImage" src="<?= $imgSrc ?>" alt="Beitragsbild">
</a>
</div>
<?php endif; ?>
<?php
$linkRaw = trim((string) ($currentVersion['link'] ?? ''));
$linkLabelRaw = trim((string) ($currentVersion['link_label'] ?? ''));
if ($linkRaw !== '') {
// Fallback Label: wenn kein Label angegeben, URL als Label nutzen
$label = $linkLabelRaw !== '' ? $linkLabelRaw : $linkRaw;
// Falls es eine rohe Domain ohne Schema ist, https:// voranstellen
$hrefForCheck = $linkRaw;
if (!preg_match('~^(?:https?:)?//|mailto:|/|#~i', $hrefForCheck)) {
$hrefForCheck = 'https://' . $hrefForCheck;
}
$safeUrl = htmlspecialchars($hrefForCheck, ENT_QUOTES, 'UTF-8');
$safeLabel = htmlspecialchars($label, ENT_QUOTES, 'UTF-8');
?>
<div class="post-aside-section">
<h3><?= $translation->get("Link") ?></h3>
<div class="post-link">
<a href="<?= $safeUrl ?>" target="_blank" rel="noopener noreferrer"><?= $safeLabel ?></a>
</div>
</div>
<?php } ?>
<?php if (!empty($currentVersion['summary'])): ?>
<div class="post-aside-section post-summary-section">
<h3>Zusammenfassung</h3>
<div class="post-summary">
<p><?= nl2br(htmlspecialchars($currentVersion['summary'])) ?></p>
</div>
</div>
<?php endif; ?>
<div id="filesContainer"></div>
<div id="contactsContainer"></div>
<div class="post-aside-section">
<h3><?= $translation->get("last_change_on") ?></h3>
<div class="post-meta">
<?php
$timestamp = strtotime($currentVersion['modified_at']);
$fmt = new IntlDateFormatter('de_DE', IntlDateFormatter::LONG, IntlDateFormatter::SHORT);
$formattedDate = $fmt->format($timestamp);
?>
<div class="post-meta-item">
<?= htmlspecialchars($formattedDate) ?> - <?= htmlspecialchars($currentVersion['author']) ?>
</div>
</div>
</div>
<?php endif; ?>
</div><!-- /.post-right-container -->
</div><!-- /.post-grid-container -->
<script>
$(document).ready(function () {
// ---- Kontakte laden ----
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "get_contact_cards",
post_id: <?= $postId ?>
}, "json")
.done(function (response) {
if (response.success && $.trim(response.html).length > 0) {
var html = "<div class='post-aside-section'><h3><?= $translation->get("Ansprechpartner") ?></h3>";
html += "<div id='contacts-content'>" + response.html + "</div></div>";
$("#contactsContainer").html(html);
} else {
$("#contactsContainer").empty();
}
})
.fail(function () {
$("#contactsContainer").html("<p>Ein Fehler ist aufgetreten.</p>");
});
// ---- Datei-Icons definieren ----
var fileIcons = {
pdf: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="red" class="bi bi-file-earmark-pdf" viewBox="0 0 16 16"> <path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/> <path d="M4.603 14.087a.8.8 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102.198-.307.526-.568.897-.787a7.7 7.7 0 0 1 1.482-.645 20 20 0 0 0 1.062-2.227 7.3 7.3 0 0 1-.43-1.295c-.086-.4-.119-.796-.046-1.136.075-.354.274-.672.65-.823.192-.077.4-.12.602-.077a.7.7 0 0 1 .477.365c.088.164.12.356.127.538.007.188-.012.396-.047.614-.084.51-.27 1.134-.52 1.794a11 11 0 0 0 .98 1.686 5.8 5.8 0 0 1 1.334.05c.364.066.734.195.96.465.12.144.193.32.2.518.007.192-.047.382-.138.563a1.04 1.04 0 0 1-.354.416.86.86 0 0 1-.51.138c-.331-.014-.654-.196-.933-.417a5.7 5.7 0 0 1-.911-.95 11.7 11.7 0 0 0-1.997.406 11.3 11.3 0 0 1-1.02 1.51c-.292.35-.609.656-.927.787a.8.8 0 0 1-.58.029m1.379-1.901q-.25.115-.459.238c-.328.194-.541.383-.647.547-.094.145-.096.25-.04.361q.016.032.026.044l.035-.012c.137-.056.355-.235.635-.572a8 8 0 0 0 .45-.606m1.64-1.33a13 13 0 0 1 1.01-.193 12 12 0 0 1-.51-.858 21 21 0 0 1-.5 1.05zm2.446.45q.226.245.435.41c.24.19.407.253.498.256a.1.1 0 0 0 .07-.015.3.3 0 0 0 .094-.125.44.44 0 0 0 .059-.2.1.1 0 0 0-.026-.063c-.052-.062-.2-.152-.518-.209a4 4 0 0 0-.612-.053zM8.078 7.8a7 7 0 0 0 .2-.828q.046-.282.038-.465a.6.6 0 0 0-.032-.198.5.5 0 0 0-.145.04c-.087.035-.158.106-.196.283-.04.192-.03.469.046.822q.036.167.09.346z"/> </svg>',
image: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="green" class="bi bi-file-earmark-image" viewBox="0 0 16 16"> <path d="M6.502 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"/> <path d="M14 14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zM4 1a1 1 0 0 0-1 1v10l2.224-2.224a.5.5 0 0 1 .61-.075L8 11l2.157-3.02a.5.5 0 0 1 .76-.063L13 10V4.5h-2A1.5 1.5 0 0 1 9.5 3V1z"/> </svg>',
doc: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="blue" class="bi bi-file-earmark-word" viewBox="0 0 16 16"> <path d="M5.485 6.879a.5.5 0 1 0-.97.242l1.5 6a.5.5 0 0 0 .967.01L8 9.402l1.018 3.73a.5.5 0 0 0 .967-.01l1.5-6a.5.5 0 0 0-.97-.242l-1.036 4.144-.997-3.655a.5.5 0 0 0-.964 0l-.997 3.655L5.485 6.88z"/> <path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/> </svg>',
default: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-earmark-text" viewBox="0 0 16 16"> <path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5"/> <path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/> </svg>'
};
// ---- Dateien laden ----
$.ajax({
type: "POST",
url: "/module/knowledgecenter_update/Ajax/Ajax.php",
data: {
action: "get_post_files",
post_id: <?= $postId ?>
},
dataType: "json",
success: function (response) {
if (response.success && response.files && response.files.length > 0) {
var html = "<div class='post-aside-section'><h3><?= $translation->get("files") ?></h3><div id='article-files-content'>";
var imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
var pdfExts = ['pdf'];
var docExts = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
response.files.forEach(function (file, i) {
var ext = file.path.split('.').pop().toLowerCase();
if (!ext && file.description.indexOf('.') !== -1) {
ext = file.description.split('.').pop().toLowerCase();
}
var icon = fileIcons.default;
if (pdfExts.indexOf(ext) !== -1) icon = fileIcons.pdf;
else if (imageExts.indexOf(ext) !== -1) icon = fileIcons.image;
else if (docExts.indexOf(ext) !== -1) icon = fileIcons.doc;
html += "<div class='fileItem'>" + icon + " ";
if (pdfExts.indexOf(ext) !== -1) {
var embedId = "sidebar_pdf_" + i;
html += "<a href='#" + embedId + "' data-fslightbox='sidebar_gallery' data-type='inline'>" + file.description + "</a>";
html += "<object id='" + embedId + "' data='" + file.path + "' type='application/pdf' width='100%' height='900px'></object>";
} else if (imageExts.indexOf(ext) !== -1) {
html += "<a href='" + file.path + "' data-fslightbox='sidebar_gallery'>" + file.description + "</a>";
} else {
html += "<a href='" + file.path + "' download>" + file.description + "</a>";
}
html += "</div><hr>";
});
html += "</div></div>";
$("#filesContainer").html(html);
refreshFsLightbox();
} else {
$("#filesContainer").empty();
}
},
error: function () {
$("#filesContainer").html("<p>Ein Fehler ist aufgetreten.</p>");
}
});
});
</script>

View File

@@ -0,0 +1,113 @@
<?php
// -------------------- Ansprechpartner für Post --------------------
$postContactsData = [];
$query = "SELECT id, name FROM main_contact ORDER BY name ASC";
$result = mysqli_query($GLOBALS['mysql_con'], $query);
while ($row = mysqli_fetch_assoc($result)) {
$postContactsData[] = ['value' => $row['id'], 'name' => $row['name']];
}
$selectedPostContacts = [];
if ($postId != 0) {
$querySelected = "SELECT mc.id, mc.name
FROM knowledgecenter_post_contacts AS pc
JOIN main_contact AS mc ON pc.main_contact_id = mc.id
WHERE pc.post_id = $postId";
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
while ($row = mysqli_fetch_assoc($resultSelected)) {
$selectedPostContacts[] = ['value' => $row['id'], 'name' => $row['name']];
}
}
?>
<div class="titlebar_small">
<h2><?=$translation->get("Ansprechpartner")?></h2>
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
<span data-toggle-target="contacts_form" class="toggle post-action-btn-small green ">+</span>
<?php } ?>
</div>
<hr>
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
<div data-toggle-target="contacts_form" class="toggle_container">
<div class="settings_container">
<input type="text" id="postContactTags" name="postContacts" placeholder="<?=$translation->get("Ansprechpartner_hinzufuegen")?>" />
<span id="saveContacts" title="Speichern" class="post-action-btn green"><?= $saveIcon ?></span>
</div>
</div>
<?php } ?>
<div id="contactCards"></div>
<script>
var postContactsData = <?php echo json_encode($postContactsData); ?>;
var selectedPostContacts = <?php echo json_encode($selectedPostContacts); ?>;
// Ansprechpartner mit Tagify initialisieren
var tagifyPostContacts = new Tagify(document.getElementById('postContactTags'), {
whitelist: postContactsData,
tagTextProp: 'name',
enforceWhitelist: true,
dropdown: {
enabled: 0,
maxItems: Infinity,
searchKeys: ["name"]
},
templates: {
dropdownItem: function (tagData) {
return `<div ${this.getAttributes(tagData)} class="tagify__dropdown__item">
<span>${tagData.name}</span>
</div>`;
}
}
});
if (selectedPostContacts.length) {
tagifyPostContacts.addTags(selectedPostContacts);
}
// Speichern der Kontakte via AJAX (wie bereits implementiert)
$("#saveContacts").on("click", function () {
$("#tab-contacts").addClass("blurred");
var selectedContacts = tagifyPostContacts.value.map(tag => tag.value);
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "update_post_contacts",
post_id: <?= $postId ?>,
contacts: JSON.stringify(selectedContacts)
}, "json")
.done(function (response) {
if (response.success) {
showMessage("Alle Daten erfolgreich gespeichert!", true);
location.reload();
} else {
showMessage("Fehler beim Speichern: Ansprechpartner", false);
}
})
.fail(function () {
showMessage("Es ist ein Fehler beim Speichern aufgetreten!", false);
})
.always(function () {
$("#tab-contacts").removeClass("blurred");
});
});
<?php if ($postId > 0): ?>
// Direktes Laden und Anzeigen der Visitenkarten per AJAX beim Laden der Seite
$(document).ready(function () {
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "get_contact_cards",
post_id: <?= $postId ?>
}, "json")
.done(function (response) {
if (response.success) {
$("#contactCards").html(response.html);
} else {
$("#contactCards").html('<p>Fehler: ' + response.error + '</p>');
}
})
.fail(function () {
$("#contactCards").html('<p>Ein Fehler ist aufgetreten.</p>');
});
});
<?php endif; ?>
</script>

View File

@@ -0,0 +1,195 @@
<div class="titlebar_small">
<h2><?=$translation->get("Diskussion")?></h2>
<span data-toggle-target="discussion_form" class="toggle post-action-btn-small green">+</span>
</div>
<hr>
<div data-toggle-target="discussion_form" class="toggle_container">
<form id="newCommentForm">
<input type="hidden" name="post_id" value="<?php echo $postId; ?>">
<input type="hidden" name="author_id" value="<?php echo $GLOBALS['main_contact']['id']; ?>">
<input type="text" class="input_round" name="text" placeholder="Text..." required>
<button class="send_comment" title="Senden" type="submit">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-send-fill"
viewBox="0 0 16 16">
<path
d="M15.964.686a.5.5 0 0 0-.65-.65L.767 5.855H.766l-.452.18a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41a.5.5 0 0 0 .886-.083zm-1.833 1.89L6.637 10.07l-.215-.338a.5.5 0 0 0-.154-.154l-.338-.215 7.494-7.494 1.178-.471z" />
</svg>
</button>
</form>
<hr>
</div>
<div id="commentsContainer">
</div>
<script>
var currentUserId = <?php echo json_encode($GLOBALS['main_contact']['id']); ?>;
$(document).ready(function () {
// Funktion zum Anzeigen des Antwortformulars
$(document).on("click", ".replyCommentBtn", function () {
var parentId = $(this).data("id");
var $comment = $(this).closest('.comment');
// Finde den entsprechenden Reply-Container
var $replyContainer = $comment.find("div.replies[data-parent-id='" + parentId + "']");
// Falls der Container nicht existiert, erstelle ihn
if ($replyContainer.length === 0) {
$comment.append("<div class='replies' data-parent-id='" + parentId + "'></div>");
$replyContainer = $comment.find("div.replies[data-parent-id='" + parentId + "']");
}
// Prüfen, ob bereits ein Reply-Formular vorhanden ist
if ($replyContainer.find(".replyCommentForm").length > 0) {
// Eventuell: Formular toggle ausblenden/anzeigen oder
// einfach nichts tun, um Mehrfach-Erzeugung zu vermeiden
return; // Verhindert, dass ein weiteres Formular hinzugefügt wird
}
// Erzeuge das Reply-Formular und füge es ein
var replyFormHtml = "<form class='replyCommentForm'>" +
"<input type='hidden' name='parent_id' value='" + parentId + "'>" +
"<input type='hidden' name='post_id' value='<?php echo $postId; ?>'>" +
"<input type='hidden' name='author_id' value='<?php echo $GLOBALS['main_contact']['id']; ?>'>" +
"<input type='hidden' name='action' value='insert_comment'>" +
"<input type='text' class='input_round' name='text' placeholder='<?=$translation->get("reply_placeholder")?>'>" +
"<button class='send_comment' type='submit'>↪</button>" +
"</form>";
$replyContainer.append(replyFormHtml);
});
// Absenden des Antwortformulars
$(document).on("submit", ".replyCommentForm", function (e) {
e.preventDefault();
var formData = $(this).serialize();
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", formData, function (response) {
if (response.success) {
// Kommentare neu laden alternativ kann das neue Reply direkt angezeigt werden
loadComments();
} else {
alert("Fehler: " + response.error);
}
}, "json");
});
// Die bestehende loadComments()-Funktion anpassen:
function loadComments() {
// $("#overlayLoader").show(); // Zeige den Loader an
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: 'get_post_comments',
post_id: <?php echo $postId; ?>
}, function (response) {
if (response.success) {
var html = '';
if (response.comments.length === 0) {
html = "<div class='no-comments'><?=$translation->get("no_comments")?></div>";
} else {
var commentMap = {};
response.comments.forEach(function (comment) {
commentMap[comment.id] = comment;
comment.children = []; // initialisiere ein Array für Antworten
});
var roots = [];
response.comments.forEach(function (comment) {
if (comment.parent_id && commentMap[comment.parent_id]) {
commentMap[comment.parent_id].children.push(comment);
} else {
roots.push(comment);
}
});
function renderComment(comment, isLast) {
var d = new Date(comment.created_at);
var formattedDate = d.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
});
// Füge die Zusatzklasse hinzu, wenn isLast true ist
var extraClass = isLast ? " last-comment" : "";
var commentHtml = "<div class='comment" + extraClass + "' data-id='" + comment.id + "'>";
commentHtml += " <div class='comment-content'>";
commentHtml += " <div class='comment-header'>";
commentHtml += " <strong>" + comment.author + "</strong> - <small>" + formattedDate + "</small>";
commentHtml += " <p>" + comment.text + "</p>";
commentHtml += " </div>";
commentHtml += " <div class='comment-toolbar'>";
commentHtml += " <span class='replyCommentBtn post-action-btn-small green' data-id='" + comment.id + "'>↪</span>";
if (comment.author_id == currentUserId) {
commentHtml += " <span class='deleteCommentBtn post-action-btn-small' data-id='" + comment.id + "' title='Kommentar löschen'>✕</span>";
}
commentHtml += " </div>";
commentHtml += " </div>";
// Wenn Kinder vorhanden sind, durchlaufe diese
if (comment.children && comment.children.length > 0) {
commentHtml += " <div class='replies' data-parent-id='" + comment.id + "'>";
comment.children.forEach(function (child, index, array) {
// Bestimme, ob es das letzte Kind im Array ist
var isLastChild = (index === array.length - 1);
commentHtml += renderComment(child, isLastChild);
});
commentHtml += " </div>";
}
commentHtml += "</div>";
return commentHtml;
}
// Beim Rendern der Wurzeln rufst du renderComment() auf, hier kannst du z.B. false übergeben, da es beim Wurzelelement nicht relevant ist:
roots.forEach(function (comment) {
// Wenn du weißt, dass das Wurzelelement möglicherweise auch das letzte im Container ist,
// kannst du den selben Check machen (z.B. isLast = index === roots.length-1)
html += renderComment(comment, false);
});
}
$("#commentsContainer").html(html);
}
}, "json").always(function () {
// $("#overlayLoader").hide(); // Blende den Loader aus, egal ob Erfolg oder Fehler
});
}
// Lade Kommentare beim Initialisieren
loadComments();
// Bestehender Code für das neue Kommentarformular bleibt erhalten...
$("#newCommentForm").on("submit", function (e) {
e.preventDefault();
var $btn = $(this).find("button[type='submit']");
$btn.prop("disabled", true);
var formData = $(this).serialize() + "&action=insert_comment";
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", formData, function (response) {
if (response.success) {
loadComments();
$("#newCommentForm input[name='text']").val('');
} else {
alert("Fehler: " + response.error);
}
$btn.prop("disabled", false); // Reaktivieren nach Abschluss
}, "json");
});
// Löschen eines Kommentars (bestehender Code) bleibt grundsätzlich gleich.
$(document).on("click", ".deleteCommentBtn", function () {
var commentId = $(this).data("id");
if (confirm("Kommentar wirklich löschen?")) {
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", { action: 'delete_comment', comment_id: commentId, main_contact_id: <?php echo $GLOBALS["main_contact"]["id"]; ?> }, function (response) {
if (response.success) {
loadComments();
} else {
alert("Fehler: " + response.error);
}
}, "json");
}
});
});
</script>

View File

@@ -0,0 +1,148 @@
<h2>
<?php
// echo "<pre>";
// var_dump($translations);
// echo "</pre>";
if (!$isNew) {
?>
<span id="cancelEditBtn" style="cursor:pointer;">←</span>
<?php
} else {
?>
<a href="javascript:history.back()">←</a>
<?php
}
?>
<span id="editAreaTitle"><?= $translation->get("edit_version") ?></span>
</h2>
<!-- Language Tabs -->
<div class="language-tabs tab-bar">
<?php foreach ($allLanguages as $lang): ?>
<div data-lang="<?= $lang['id'] ?>" class="<?= ($lang['id'] == $languageId) ? 'active' : '' ?>">
<?= htmlspecialchars(strtoupper($lang['code'])) ?>
</div>
<?php endforeach; ?>
</div>
<form id="editVersionForm">
<!-- Gemeinsame Hidden-Felder -->
<input type="hidden" name="post_id" value="<?= $postId; ?>">
<div class="edit-columns">
<!-- Left column: Main content editing -->
<div class="edit-main">
<?php foreach ($allLanguages as $lang):
$langId = $lang['id'];
$trans = isset($translations[$langId]) ? $translations[$langId] : ['title' => '', 'image' => '', 'teaser' => '', 'content' => '', 'summary' => ''];
?>
<div class="lang-form" id="lang_<?= $langId ?>" data-lang="<?= $langId ?>"
style="<?= ($langId == $languageId) ? '' : 'display: none;' ?>">
<input type="hidden" name="version_id[<?= $langId ?>]" value="<?= $trans['id'] ?>">
<label for="title_<?= $langId ?>">Titel (<?= htmlspecialchars($lang['code']) ?>):</label>
<input type="text" id="title_<?= $langId ?>" name="title[<?= $langId ?>]"
value="<?= htmlspecialchars($trans['title']) ?>">
<div class="single_image_group">
<?php
$imageIsValid = (!empty($trans['image']) && $trans['image'] !== 'null');
$imagePath = $imageIsValid ? '/userdata/' . htmlspecialchars($trans['image']) : '/userdata/knowledgecenter/platzhalter.jpg';
?>
<img class="preview" id="preview-<?= $lang['code'] ?>" src="<?= $imagePath ?>" alt="Bildvorschau"
style="max-width: 100px; min-height: 60px; border-radius: 5px; <?= $imageIsValid ? '' : 'background-color:#f3f3f3;'; ?>">
<input type="hidden" id="image_<?= $langId ?>" name="image[<?= $langId ?>]"
value="<?= htmlspecialchars($trans['image']) ?>">
<div class="post-action-btn grey openFileGallery" data-target="image_<?= $langId ?>">+</div>
<div class="post-action-btn grey clearImage" data-target="image_<?= $langId ?>">✕</div>
</div>
<label for="teaser_<?= $langId ?>"><?= $translation->get("Teaser") ?></label>
<textarea id="teaser_<?= $langId ?>"
name="teaser[<?= $langId ?>]"><?= htmlspecialchars($trans['teaser']) ?></textarea>
<hr>
<div id="editor_<?= $langId ?>" class="editor">
<?= $translations[$langId]['content'] ?>
</div>
<hr>
<label for="summary_<?= $langId ?>">Zusammenfassung (Summary)</label>
<textarea id="summary_<?= $langId ?>" name="summary[<?= $langId ?>]" rows="4"
placeholder="Kurze Zusammenfassung des Artikelinhalts..."><?= htmlspecialchars($trans['summary'] ?? '') ?></textarea>
</div>
<?php endforeach; ?>
</div>
<!-- Right column: Sidebar with save button and meta fields -->
<div class="edit-sidebar">
<?php if ($canEditPost ?? false) { ?>
<span title="<?= $translation->get("save") ?>" id="saveVersionBtn" class="post-action-btn-text green">
<?= $translation->get("save") ?>
</span>
<?php } ?>
<hr>
<?php foreach ($allLanguages as $lang):
$langId = $lang['id'];
$trans = isset($translations[$langId]) ? $translations[$langId] : ['title' => '', 'image' => '', 'teaser' => '', 'content' => '', 'summary' => ''];
?>
<div class="lang-form-meta" data-lang="<?= $langId ?>"
style="<?= ($langId == $languageId) ? '' : 'display: none;' ?>">
<div class="post-edit-meta">
<label for="link_<?= $langId ?>">Link
<input type="url" id="link_<?= $langId ?>" name="link[<?= $langId ?>]"
value="<?= htmlspecialchars($trans['link'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
placeholder="https://...">
</label>
<label for="link_label_<?= $langId ?>">Link-Label
<input type="text" id="link_label_<?= $langId ?>" name="link_label[<?= $langId ?>]"
value="<?= htmlspecialchars($trans['link_label'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
placeholder="z. B. Mehr erfahren">
</label>
<hr>
<?php
$fromVal = (
!empty($trans['valid_from'])
&& $trans['valid_from'] !== '0000-00-00 00:00:00'
)
? date('Y-m-d', strtotime($trans['valid_from']))
: '';
$untilVal = (
!empty($trans['valid_until'])
&& $trans['valid_until'] !== '0000-00-00 00:00:00'
)
? date('Y-m-d', strtotime($trans['valid_until']))
: '';
?>
<label for="valid_from_<?= $langId ?>">
Gültig ab:
<input type="date" id="valid_from_<?= $langId ?>" name="valid_from[<?= $langId ?>]"
value="<?= htmlspecialchars($fromVal) ?>">
</label>
<label for="valid_until_<?= $langId ?>">
Gültig bis:
<input type="date" id="valid_until_<?= $langId ?>" name="valid_until[<?= $langId ?>]"
value="<?= htmlspecialchars($untilVal) ?>">
</label>
<label for="state_<?= $langId ?>">Status:
<select id="state_<?= $langId ?>" name="state[<?= $langId ?>]">
<option value="1" <?= (isset($trans['state']) && $trans['state'] == 1) ? 'selected' : '' ?>>
Aktiv
</option>
<option value="0" <?= (isset($trans['state']) && $trans['state'] == 0) ? 'selected' : '' ?>>
Inaktiv
</option>
</select>
</label>
<div class="keep-modified-at-field">
<label for="keep_modified_at" class="keep-modified-at-label"><?= $translation->get("keep_modified_at") ?></label>
<input type="checkbox" id="keep_modified_at" name="keep_modified_at" value="1">
</div>
<input type="hidden" name="author_id[<?= $langId ?>]" value="<?php echo $GLOBALS['main_contact']['id']; ?>">
<input type="hidden" name="content[<?= $langId ?>]" id="hiddenContent_<?= $langId ?>">
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</form>

View File

@@ -0,0 +1,186 @@
<div class="titlebar_small">
<h2><?= $translation->get("files") ?></h2>
<?php if (($canEditPost ?? false)) { ?>
<div class="post-action-btn-small green openFileGalleryMulti" title="Datei hinzufügen"
data-ajax-function="saveFilesAjax" data-target="post_files">+
</div>
<?php } ?>
<div class="post-action-btn green" id="saveFilesBtn" style="display:none;"><?= $saveIcon ?></div>
</div>
<hr>
<form id="postSettingsForm">
<input type="hidden" id="post_id" name="post_id" value="<?php echo $postId; ?>">
<input type="hidden" id="post_files" name="post_files">
</form>
<div id="filesDisplayContainer"></div>
<script>
var fileIcons = {
pdf: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="red" class="bi bi-file-earmark-pdf" viewBox="0 0 16 16"> <path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/> <path d="M4.603 14.087a.8.8 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102.198-.307.526-.568.897-.787a7.7 7.7 0 0 1 1.482-.645 20 20 0 0 0 1.062-2.227 7.3 7.3 0 0 1-.43-1.295c-.086-.4-.119-.796-.046-1.136.075-.354.274-.672.65-.823.192-.077.4-.12.602-.077a.7.7 0 0 1 .477.365c.088.164.12.356.127.538.007.188-.012.396-.047.614-.084.51-.27 1.134-.52 1.794a11 11 0 0 0 .98 1.686 5.8 5.8 0 0 1 1.334.05c.364.066.734.195.96.465.12.144.193.32.2.518.007.192-.047.382-.138.563a1.04 1.04 0 0 1-.354.416.86.86 0 0 1-.51.138c-.331-.014-.654-.196-.933-.417a5.7 5.7 0 0 1-.911-.95 11.7 11.7 0 0 0-1.997.406 11.3 11.3 0 0 1-1.02 1.51c-.292.35-.609.656-.927.787a.8.8 0 0 1-.58.029m1.379-1.901q-.25.115-.459.238c-.328.194-.541.383-.647.547-.094.145-.096.25-.04.361q.016.032.026.044l.035-.012c.137-.056.355-.235.635-.572a8 8 0 0 0 .45-.606m1.64-1.33a13 13 0 0 1 1.01-.193 12 12 0 0 1-.51-.858 21 21 0 0 1-.5 1.05zm2.446.45q.226.245.435.41c.24.19.407.253.498.256a.1.1 0 0 0 .07-.015.3.3 0 0 0 .094-.125.44.44 0 0 0 .059-.2.1.1 0 0 0-.026-.063c-.052-.062-.2-.152-.518-.209a4 4 0 0 0-.612-.053zM8.078 7.8a7 7 0 0 0 .2-.828q.046-.282.038-.465a.6.6 0 0 0-.032-.198.5.5 0 0 0-.145.04c-.087.035-.158.106-.196.283-.04.192-.03.469.046.822q.036.167.09.346z"/> </svg>',
image: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="green" class="bi bi-file-earmark-image" viewBox="0 0 16 16"> <path d="M6.502 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"/> <path d="M14 14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zM4 1a1 1 0 0 0-1 1v10l2.224-2.224a.5.5 0 0 1 .61-.075L8 11l2.157-3.02a.5.5 0 0 1 .76-.063L13 10V4.5h-2A1.5 1.5 0 0 1 9.5 3V1z"/> </svg>',
doc: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="blue" class="bi bi-file-earmark-word" viewBox="0 0 16 16"> <path d="M5.485 6.879a.5.5 0 1 0-.97.242l1.5 6a.5.5 0 0 0 .967.01L8 9.402l1.018 3.73a.5.5 0 0 0 .967-.01l1.5-6a.5.5 0 0 0-.97-.242l-1.036 4.144-.997-3.655a.5.5 0 0 0-.964 0l-.997 3.655L5.485 6.88z"/> <path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/> </svg>',
default: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-earmark-text" viewBox="0 0 16 16"> <path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5"/> <path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/> </svg>'
};
$("#saveFilesBtn").click(function () {
$.ajax({
type: "POST",
url: "/module/knowledgecenter_update/Ajax/Ajax.php",
data: {
action: 'save_post_files',
post_id: $('#post_id').val(),
post_files: $('#post_files').val()
},
dataType: "json",
success: function (response) {
if (response.success) {
// console.log("Dateien erfolgreich gespeichert.");
location.reload(); // Seite neu laden, um die Änderungen anzuzeigen
} else {
console.error("Fehler beim Speichern der Dateien:", response.error);
}
},
error: function (xhr, status, error) {
console.error("AJAX Fehler:", error);
}
});
});
window.saveFilesAjax = function () {
$.ajax({
type: "POST",
url: "/module/knowledgecenter_update/Ajax/Ajax.php",
data: {
action: 'save_post_files',
post_id: $('#post_id').val(),
post_files: $('#post_files').val()
},
dataType: "json",
success: function (response) {
if (response.success) {
console.log("Dateien erfolgreich gespeichert.");
location.reload();
} else {
console.error("Fehler beim Speichern der Dateien:", response.error);
}
},
error: function (xhr, status, error) {
console.error("AJAX Fehler:", error);
}
});
}
function updateFileDisplayFromServer() {
$.ajax({
type: "POST",
url: "/module/knowledgecenter_update/Ajax/Ajax.php",
data: {
action: 'get_post_files',
post_id: $('#post_id').val()
},
dataType: "json",
success: function (response) {
if (response.success) {
var html = "";
// Definiere Listen für die verschiedenen Dateitypen
var imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
var pdfExtensions = ['pdf'];
var downloadableExtensions = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
response.files.forEach(function (file, index) {
// Ermitteln der Dateiendung (in Kleinbuchstaben)
var fileExt = file.path.split('.').pop().toLowerCase();
// Falls keine Endung im Pfad enthalten ist, die Endung anhand der Beschreibung ermitteln:
if (file.path.indexOf('.') === -1 && file.description.indexOf('.') !== -1) {
fileExt = file.description.split('.').pop().toLowerCase();
}
index = index + 1; // Beginne bei 1
// Bestimme den passenden Icon-Code, basierend auf der Dateiendung
var icon;
if (fileExt === 'pdf') {
icon = fileIcons.pdf;
} else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].indexOf(fileExt) !== -1) {
icon = fileIcons.image;
} else if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].indexOf(fileExt) !== -1) {
icon = fileIcons.doc;
} else {
icon = fileIcons.default;
}
// Definiere den Lösch-Button
<?php if (($canEditPost ?? false)) { ?>
var deleteButton = "<span class='file-delete post-action-btn-small' data-file-id='" + file.id + "' onclick='deleteFile(" + file.id + ")'>✕</span>";
<?php } else {
?>
var deleteButton = "";
<?php
} ?>
// Erzeugen des HTML abhängig vom Dateityp
var htmlSegment = "<div class='fileItem'>" + icon + " ";
if (fileExt === 'pdf') {
var embedId = "embed_" + index;
htmlSegment += "<a href='#" + embedId + "' data-fslightbox='gallery' data-type='inline'>" + file.description + "</a>";
htmlSegment += deleteButton + "</div>";
htmlSegment += "<object id='" + embedId + "' data='" + file.path + "' type='application/pdf' width='100%' height='900px'></object>";
}
else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].indexOf(fileExt) !== -1) {
htmlSegment += "<a href='" + file.path + "' data-fslightbox='gallery'>" + file.description + "</a>" + deleteButton + "</div>";
}
else if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].indexOf(fileExt) !== -1) {
htmlSegment += "<a href='" + file.path + "' download>" + file.description + "</a>" + deleteButton + "</div>";
}
else {
htmlSegment += "<a href='" + file.path + "' download>" + file.description + "</a>" + deleteButton + "</div>";
}
htmlSegment += "<hr>";
html += htmlSegment;
});
$("#filesDisplayContainer").html(html);
refreshFsLightbox();
} else {
console.error("Fehler beim Abrufen der Dateien:", response.error);
}
},
error: function (xhr, status, error) {
console.error("AJAX Fehler beim Abrufen der Dateien:", error);
}
});
}
function deleteFile(fileId) {
if (confirm("Möchten Sie diese Datei wirklich löschen?")) {
$.ajax({
type: "POST",
url: "/module/knowledgecenter_update/Ajax/Ajax.php",
data: {
action: 'delete_post_file',
file_id: fileId
},
dataType: "json",
success: function (response) {
if (response.success) {
updateFileDisplayFromServer();
} else {
console.error("Fehler beim Löschen der Datei:", response.error);
}
},
error: function (xhr, status, error) {
console.error("AJAX Fehler:", error);
}
});
}
}
// Dateien direkt beim Laden der Seite abrufen
updateFileDisplayFromServer();
</script>

View File

@@ -0,0 +1,577 @@
<?php
// post_cardform_settings.php
// Sicherstellen, dass $postId gesetzt ist andernfalls neuen Beitrag (0) annehmen
if (!isset($postId)) {
$postId = 0;
}
// Falls $languageId noch nicht definiert ist, Standard (z.B. 1) verwenden
if (!isset($languageId)) {
$languageId = 1;
}
// Load current linked form for this post (if any)
$currentFormId = 0;
$res = mysqli_query($GLOBALS['mysql_con'], "
SELECT form_id
FROM knowledgecenter_post_form
WHERE post_id = $postId
LIMIT 1
");
if ($row = mysqli_fetch_assoc($res)) {
$currentFormId = (int)$row['form_id'];
}
// Build <option> list from contactform_header with 'selected' on current form
$formOptions = "";
$query = "SELECT id, description FROM contactform_header ORDER BY id ASC";
$result = mysqli_query($GLOBALS['mysql_con'], $query);
while ($row = mysqli_fetch_assoc($result)) {
// Mark the currently linked form as selected
$selected = ($row['id'] == $currentFormId) ? 'selected' : '';
$formOptions .= "<option value='" . $row['id'] . "' $selected>"
. htmlspecialchars($row['description']) . "</option>";
}
// -------------------- Kategorien --------------------
// Erzeuge die Whitelist für alle verfügbaren Kategorien (mit Übersetzung)
// Betriebsrat sieht nur Kategorien, die für ihn freigegeben sind (direkt oder über Elternkategorie)
$categoriesData = [];
$query = "SELECT c.id, t.title, c.enable_post_color_pickers
FROM knowledgecenter_categories_update AS c
LEFT JOIN knowledgecenter_category_translations AS t
ON c.id = t.category_id
WHERE t.language_id = $languageId";
$result = mysqli_query($GLOBALS['mysql_con'], $query);
$isBrOnly = !is_wiki_redakteur() && is_wiki_betriebsrat();
while ($row = mysqli_fetch_assoc($result)) {
if ($isBrOnly && !is_category_betriebsrat_editable((int)$row['id'])) {
continue;
}
$categoriesData[] = [
'value' => $row['id'],
'name' => $row['title'],
'enable_post_color_pickers' => (int) ($row['enable_post_color_pickers'] ?? 0)
];
}
// Bereits zugeordnete Kategorien abrufen (falls vorhanden)
$selectedCategories = [];
if ($postId != 0) {
$querySelected = "SELECT c.id, t.title
FROM knowledgecenter_category_post AS pc
JOIN knowledgecenter_categories_update AS c ON pc.category_id = c.id
LEFT JOIN knowledgecenter_category_translations AS t
ON c.id = t.category_id
WHERE pc.post_id = $postId AND t.language_id = $languageId";
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
while ($row = mysqli_fetch_assoc($resultSelected)) {
$selectedCategories[] = ['value' => $row['id'], 'name' => $row['title']];
}
}
// -------------------- Mandanten für Post --------------------
$postMandantsData = [];
$query = "SELECT id, description FROM main_mandant";
$result = mysqli_query($GLOBALS['mysql_con'], $query);
while ($row = mysqli_fetch_assoc($result)) {
// Hier verwenden wir 'name' als Anzeige-Text, der die description enthält.
$postMandantsData[] = ['value' => $row['id'], 'name' => $row['description']];
}
$selectedPostMandants = [];
if ($postId != 0) {
$querySelected = "SELECT m.id, m.description
FROM knowledgecenter_post_mandant AS pm
JOIN main_mandant AS m ON pm.mandant_id = m.id
WHERE pm.post_id = $postId";
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
while ($row = mysqli_fetch_assoc($resultSelected)) {
$selectedPostMandants[] = ['value' => $row['id'], 'name' => $row['description']];
}
}
// -------------------- Departments für Post --------------------
$postDepartmentsData = [];
$query = "SELECT id, description FROM main_department";
$result = mysqli_query($GLOBALS['mysql_con'], $query);
while ($row = mysqli_fetch_assoc($result)) {
$postDepartmentsData[] = ['value' => $row['id'], 'name' => $row['description']];
}
$selectedPostDepartments = [];
if ($postId != 0) {
$querySelected = "SELECT d.id, d.description
FROM knowledgecenter_post_department AS pd
JOIN main_department AS d ON pd.department_id = d.id
WHERE pd.post_id = $postId";
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
while ($row = mysqli_fetch_assoc($resultSelected)) {
$selectedPostDepartments[] = ['value' => $row['id'], 'name' => $row['description']];
}
}
// -------------------- Rollen für Post --------------------
$postRolesData = [];
$query = "SELECT id, description FROM main_role";
$result = mysqli_query($GLOBALS['mysql_con'], $query);
while ($row = mysqli_fetch_assoc($result)) {
$postRolesData[] = ['value' => $row['id'], 'name' => $row['description']];
}
$selectedPostRoles = [];
if ($postId != 0) {
$querySelected = "SELECT r.id, r.description
FROM knowledgecenter_post_role AS pr
JOIN main_role AS r ON pr.role_id = r.id
WHERE pr.post_id = $postId";
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
while ($row = mysqli_fetch_assoc($resultSelected)) {
$selectedPostRoles[] = ['value' => $row['id'], 'name' => $row['description']];
}
}
// -------------------- Einrichtungen für Post --------------------
$postEinrichtsData = [];
$query = "SELECT id, description FROM organigramm_einricht ORDER BY description ASC";
$result = mysqli_query($GLOBALS['mysql_con'], $query);
while ($row = mysqli_fetch_assoc($result)) {
$postEinrichtsData[] = ['value' => $row['id'], 'name' => $row['description']];
}
// Verknüpfungskarte für Einrichtungen (Gruppen → Mitglieder)
$postEinrichtLinks = [];
$linkRes = mysqli_query($GLOBALS['mysql_con'], "SELECT einricht_id, linked_einricht_id FROM organigramm_einricht_link");
while ($lr = mysqli_fetch_assoc($linkRes)) {
$postEinrichtLinks[strval($lr['einricht_id'])][] = strval($lr['linked_einricht_id']);
}
$selectedPostEinrichts = [];
if ($postId != 0) {
$querySelected = "SELECT e.id, e.description
FROM knowledgecenter_post_einricht AS pe
JOIN organigramm_einricht AS e ON pe.einricht_id = e.id
WHERE pe.post_id = $postId";
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
while ($row = mysqli_fetch_assoc($resultSelected)) {
$selectedPostEinrichts[] = ['value' => $row['id'], 'name' => $row['description']];
}
}
// -------------------- Fachbereiche für Post --------------------
$postFachbereicheData = [];
$query = "SELECT id, description FROM organogramm_space ORDER BY description ASC";
$result = mysqli_query($GLOBALS['mysql_con'], $query);
while ($row = mysqli_fetch_assoc($result)) {
$postFachbereicheData[] = ['value' => $row['id'], 'name' => $row['description']];
}
$selectedPostFachbereiche = [];
if ($postId != 0) {
$querySelected = "SELECT s.id, s.description
FROM knowledgecenter_post_fachbereich AS pfb
JOIN organogramm_space AS s ON pfb.fachbereich_id = s.id
WHERE pfb.post_id = $postId";
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
while ($row = mysqli_fetch_assoc($resultSelected)) {
$selectedPostFachbereiche[] = ['value' => $row['id'], 'name' => $row['description']];
}
}
$postBackgroundColor = '#FFFFFF';
if (!empty($post['background_color'])) {
$postBackgroundColor = $post['background_color'];
} elseif (!empty($currentVersion['background_color'])) {
$postBackgroundColor = $currentVersion['background_color'];
}
$postTextColor = '#000000';
if (!empty($post['text_color'])) {
$postTextColor = $post['text_color'];
} elseif (!empty($currentVersion['text_color'])) {
$postTextColor = $currentVersion['text_color'];
}
?>
<button id="message" style="opacity:0; position: absolute;"></button>
<div class="titlebar_small">
<h2><?=$translation->get("settings")?></h2>
<?php if ($canEditPost ?? false) { ?>
<span id="saveAllBtn" class="post-action-btn-small green"><?= $saveIcon ?></span>
<?php } ?>
</div>
<hr>
<div class="settings">
<form id="postSettingsForm">
<input type="hidden" id="post_id" name="post_id" value="<?php echo $postId; ?>" />
<h3><?=$translation->get("categories")?>
</h3>
<div class="settings_container">
<input type="text" id="categoryTags" name="categories" placeholder="Kategorie hinzufügen…" />
</div>
<div id="postColorPickersSection" class="post-color-grid" style="display:none;">
<div class="post-color-item">
<label for="postBackgroundColor"><?= $translation->get("post_background_color") ?></label>
<input type="color" id="postBackgroundColor" name="background_color"
value="<?= htmlspecialchars($postBackgroundColor, ENT_QUOTES, 'UTF-8') ?>">
</div>
<div class="post-color-item">
<label for="postTextColor"><?= $translation->get("post_text_color") ?></label>
<input type="color" id="postTextColor" name="text_color"
value="<?= htmlspecialchars($postTextColor, ENT_QUOTES, 'UTF-8') ?>">
</div>
</div>
<h3><?=$translation->get("Geschäftsstellen")?>
</h3>
<div class="settings_container">
<input type="text" id="postMandantTags" name="postMandants" placeholder="<?=$translation->get("Geschäftsstellen")?>…" />
</div>
<h3><?=$translation->get("departments")?>
</h3>
<div class="settings_container">
<input type="text" id="postDepartmentTags" name="postDepartments" placeholder="<?=$translation->get("departments")?>…" />
</div>
<h3><?=$translation->get("roles")?>
</h3>
<div class="settings_container">
<input type="text" id="postRoleTags" name="postRoles" placeholder="<?=$translation->get("roles")?>…" />
</div>
<h3>Einrichtungen</h3>
<div class="settings_container">
<input type="text" id="postEinrichtTags" name="postEinrichts" placeholder="Einrichtung hinzufügen…" />
</div>
<h3>Fachbereiche</h3>
<div class="settings_container">
<input type="text" id="postFachbereichTags" name="postFachbereiche" placeholder="Fachbereich hinzufügen…" />
</div>
<h3>Formular auswählen</h3>
<div class="settings_container">
<select name="postFormSelect" id="postFormSelect">
<option value="">Bitte wählen…</option>
<?php echo $formOptions; ?>
</select>
</div>
<hgroup>
<h3><?=$translation->get("pdf_inline_preview")?></h3>
<small><?=$translation->get("pdf_inline_preview_explanation")?></small>
</hgroup>
<div class="settings_container">
<label>
<input type="checkbox" id="pdfInlineCheckbox" name="pdf_inline" value="1"
<?php echo (!empty($post['preview_inline']) && $post['preview_inline'] == 1) ? 'checked' : ''; ?>>
<?=$translation->get("pdf_inline_preview_label")?>
</label>
</div>
<h3><?=$translation->get("delete_post")?></h3>
<div class="settings_container">
<div id="deletePostBtn" class="post-action-btn-text red">
<?=$translation->get("delete_post")?>
</div>
</div>
</form>
</div>
<script>
// Übergabe der Whitelist-Daten aus PHP
var categoriesData = <?php echo json_encode($categoriesData); ?>;
var postMandantsData = <?php echo json_encode($postMandantsData); ?>;
var postDepartmentsData = <?php echo json_encode($postDepartmentsData); ?>;
var postRolesData = <?php echo json_encode($postRolesData); ?>;
var postEinrichtsData = <?php echo json_encode($postEinrichtsData); ?>;
var postFachbereicheData = <?php echo json_encode($postFachbereicheData); ?>;
var selectedCategories = <?php echo json_encode($selectedCategories); ?>;
var selectedPostMandants = <?php echo json_encode($selectedPostMandants); ?>;
var selectedPostDepartments = <?php echo json_encode($selectedPostDepartments); ?>;
var selectedPostRoles = <?php echo json_encode($selectedPostRoles); ?>;
var selectedPostEinrichts = <?php echo json_encode($selectedPostEinrichts); ?>;
var selectedPostFachbereiche = <?php echo json_encode($selectedPostFachbereiche); ?>;
var categoryEnableColorMap = {};
categoriesData.forEach(function (cat) {
categoryEnableColorMap[String(cat.value)] = parseInt(cat.enable_post_color_pickers || 0, 10) === 1;
});
function updatePostColorPickerVisibility() {
var selectedValues = tagifyCategories.value.map(function (tag) {
return String(tag.value);
});
var hasEnabledCategory = selectedValues.some(function (catId) {
return categoryEnableColorMap[catId] === true;
});
if (hasEnabledCategory) {
$("#postColorPickersSection").css("display", "grid");
} else {
$("#postColorPickersSection").hide();
}
}
// Kategorien
var tagifyCategories = new Tagify(document.getElementById('categoryTags'), {
whitelist: categoriesData,
tagTextProp: 'name',
enforceWhitelist: true,
dropdown: {
enabled: 0,
maxItems: Infinity,
searchKeys: ["name"]
},
templates: {
dropdownItem: function (tagData) {
return `<div ${this.getAttributes(tagData)} class="tagify__dropdown__item">
<span>${tagData.name}</span>
</div>`;
}
}
});
if (selectedCategories.length) {
tagifyCategories.addTags(selectedCategories);
}
tagifyCategories.on('add', updatePostColorPickerVisibility);
tagifyCategories.on('remove', updatePostColorPickerVisibility);
updatePostColorPickerVisibility();
// Mandanten für Post
var tagifyPostMandants = new Tagify(document.getElementById('postMandantTags'), {
whitelist: postMandantsData,
tagTextProp: 'name',
enforceWhitelist: true,
dropdown: { enabled: 0, maxItems: Infinity, searchKeys: ["name"] },
templates: {
dropdownItem: function (tagData) {
return `<div ${this.getAttributes(tagData)} class="tagify__dropdown__item">
<span>${tagData.name}</span>
</div>`;
}
}
});
if (selectedPostMandants.length) {
tagifyPostMandants.addTags(selectedPostMandants);
}
// Departments für Post
var tagifyPostDepartments = new Tagify(document.getElementById('postDepartmentTags'), {
whitelist: postDepartmentsData,
tagTextProp: 'name',
enforceWhitelist: true,
dropdown: { enabled: 0, maxItems: Infinity, searchKeys: ["name"] },
templates: {
dropdownItem: function (tagData) {
return `<div ${this.getAttributes(tagData)} class="tagify__dropdown__item">
<span>${tagData.name}</span>
</div>`;
}
}
});
if (selectedPostDepartments.length) {
tagifyPostDepartments.addTags(selectedPostDepartments);
}
// Rollen für Post
var tagifyPostRoles = new Tagify(document.getElementById('postRoleTags'), {
whitelist: postRolesData,
tagTextProp: 'name',
enforceWhitelist: true,
dropdown: { enabled: 0, maxItems: Infinity, searchKeys: ["name"] },
templates: {
dropdownItem: function (tagData) {
return `<div ${this.getAttributes(tagData)} class="tagify__dropdown__item">
<span>${tagData.name}</span>
</div>`;
}
}
});
if (selectedPostRoles.length) {
tagifyPostRoles.addTags(selectedPostRoles);
}
// Einrichtungen für Post
var tagifyPostEinrichts = new Tagify(document.getElementById('postEinrichtTags'), {
whitelist: postEinrichtsData,
tagTextProp: 'name',
enforceWhitelist: true,
dropdown: { enabled: 0, maxItems: Infinity, searchKeys: ["name"] },
templates: {
dropdownItem: function (tagData) {
return `<div ${this.getAttributes(tagData)} class="tagify__dropdown__item">
<span>${tagData.name}</span>
</div>`;
}
}
});
if (selectedPostEinrichts.length) {
tagifyPostEinrichts.addTags(selectedPostEinrichts);
}
var postEinrichtLinks = <?= json_encode($postEinrichtLinks) ?>;
tagifyPostEinrichts.on('add', function(e) {
var addedId = String(e.detail.data.value);
(postEinrichtLinks[addedId] || []).forEach(function(lid) {
var exists = tagifyPostEinrichts.value.some(function(t) { return String(t.value) === lid; });
if (!exists) {
var item = postEinrichtsData.find(function(d) { return String(d.value) === lid; });
if (item) tagifyPostEinrichts.addTags([item]);
}
});
});
// Fachbereiche für Post
var tagifyPostFachbereiche = new Tagify(document.getElementById('postFachbereichTags'), {
whitelist: postFachbereicheData,
tagTextProp: 'name',
enforceWhitelist: true,
dropdown: { enabled: 0, maxItems: Infinity, searchKeys: ["name"] },
templates: {
dropdownItem: function (tagData) {
return `<div ${this.getAttributes(tagData)} class="tagify__dropdown__item">
<span>${tagData.name}</span>
</div>`;
}
}
});
if (selectedPostFachbereiche.length) {
tagifyPostFachbereiche.addTags(selectedPostFachbereiche);
}
<?php if ($canEditPost ?? false) { ?>
$("#saveAllBtn").on("click", function () {
// Während des Speicherns blurren
$("#tab-settings").addClass("blurred");
var postId = $("#post_id").val();
var pdfInline = $("#pdfInlineCheckbox").prop("checked") ? 1 : 0;
var selectedCat = tagifyCategories.value.map(tag => tag.value);
var selectedMandants = tagifyPostMandants.value.map(tag => tag.value);
var selectedDepartments = tagifyPostDepartments.value.map(tag => tag.value);
var selectedRoles = tagifyPostRoles.value.map(tag => tag.value);
var selectedEinrichts = tagifyPostEinrichts.value.map(tag => tag.value);
var selectedFachbereiche = tagifyPostFachbereiche.value.map(tag => tag.value);
var selectedFormId = $("#postFormSelect").val();
var postBackgroundColor = $("#postBackgroundColor").val();
var postTextColor = $("#postTextColor").val();
$.when(
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "update_post_categories",
post_id: postId,
categories: JSON.stringify(selectedCat)
}, "json"),
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "update_post_mandants",
post_id: postId,
mandants: JSON.stringify(selectedMandants)
}, "json"),
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "update_post_form",
post_id: postId,
form_id: selectedFormId
}, "json"),
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "update_post_departments",
post_id: postId,
departments: JSON.stringify(selectedDepartments)
}, "json"),
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "update_post_roles",
post_id: postId,
roles: JSON.stringify(selectedRoles)
}, "json"),
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "update_post_einrichts",
post_id: postId,
einrichts: JSON.stringify(selectedEinrichts)
}, "json"),
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "update_post_fachbereiche",
post_id: postId,
fachbereiche: JSON.stringify(selectedFachbereiche)
}, "json"),
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "update_post_pdf_inline",
post_id: postId,
pdf_inline: pdfInline
}, "json"),
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "update_post_colors",
post_id: postId,
background_color: postBackgroundColor,
text_color: postTextColor
}, "json")
).done(function (categoriesResponse, mandantsResponse, formResponse, departmentsResponse, rolesResponse, einrichtsResponse, fachbereicheResponse, pdfInlineResponse, postColorsResponse) {
var catSuccess = categoriesResponse[0].success;
var mandantsSuccess = mandantsResponse[0].success;
var formSuccess = formResponse[0].success;
var departmentsSuccess = departmentsResponse[0].success;
var rolesSuccess = rolesResponse[0].success;
var einrichtsSuccess = einrichtsResponse[0].success;
var fachbereicheSuccess = fachbereicheResponse[0].success;
var pdfInlineSuccess = pdfInlineResponse[0].success;
var postColorsSuccess = postColorsResponse[0].success;
if (catSuccess && mandantsSuccess && formSuccess && departmentsSuccess && rolesSuccess && einrichtsSuccess && fachbereicheSuccess && pdfInlineSuccess && postColorsSuccess) {
showMessage("Alle Daten erfolgreich gespeichert!", true);
} else {
var errorMsg = "Fehler beim Speichern:";
if (!catSuccess) errorMsg += " Kategorien";
if (!mandantsSuccess) errorMsg += " Mandanten";
if (!formSuccess) errorMsg += " Formular";
if (!departmentsSuccess) errorMsg += " Departments";
if (!rolesSuccess) errorMsg += " Rollen";
if (!einrichtsSuccess) errorMsg += " Einrichtungen";
if (!fachbereicheSuccess) errorMsg += " Fachbereiche";
if (!pdfInlineSuccess) errorMsg += " PDF Inline Vorschau";
if (!postColorsSuccess) errorMsg += " Farben";
showMessage(errorMsg, false);
}
}).fail(function () {
showMessage("Es ist ein Fehler beim Speichern aufgetreten!", false);
}).always(function () {
$("#tab-settings").removeClass("blurred");
});
});
<?php } ?>
// Post löschen
$("#deletePostBtn").on("click", function() {
if (!confirm("<?= addslashes($translation->get("delete_post_confirm")) ?>")) {
return;
}
// Ajax-Aufruf an Deinen neuen Case
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
action: "delete_post",
post_id: <?=$postId?>
}, function(response) {
if (response.success) {
// Weiterleitung nach dem Löschen, z.B. zurück zur Übersicht
window.location.href = "?action=Category";
} else {
showMessage("Fehler beim Löschen: " + (response.error || "Unbekannter Fehler"), false);
}
}, "json").fail(function() {
showMessage("Serverfehler beim Löschen.", false);
});
});
</script>

View File

@@ -0,0 +1,39 @@
<div id="versionList">
<?php foreach ($versions as $ver): ?>
<?php
$selected = ($ver['id'] == $currentVersion['id']) ? "version_selected" : "";
// Formatierung der Gültigkeitszeiträume:
$validFrom = !empty($ver['valid_from'])
? date("d.m.Y H:i", strtotime($ver['valid_from']))
: $translation->get("unlimited");
$validUntil = !empty($ver['valid_until'])
? date("d.m.Y H:i", strtotime($ver['valid_until']))
: $translation->get("unlimited");
?>
<div class="version_item <?= $selected ?>" data-id="<?= $ver['id'] ?>"
data-version-number="<?= $ver['version_number'] ?>">
<div class="version_header">
<div>
<?= $translation->get("version") ?> <?= $ver['version_number'] ?>
- <?= htmlspecialchars($ver['title']) ?>
<span class="status <?= $ver['state'] ? 'active' : 'inactive' ?>">
</span>
</div>
</div>
<div class="version_validity">
<?= $translation->get("valid_from") ?>: <?= $validFrom ?>
<?= $translation->get("valid_until") ?>: <?= $validUntil ?>
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1): ?>
<span class="deleteVersionBtn red post-action-btn" data-id="<?= $ver['id'] ?>"
data-version-number="<?= $ver['version_number'] ?>">
</span>
<?php endif; ?>
</div>
</div>
<hr>
<?php endforeach; ?>
</div>

View File

@@ -0,0 +1,119 @@
<?php
// post_listform.php
// Redirect-URL für den Zurück-Button bzw. Navigation
$redirectURL = isset($_GET['redirectURL']) ? $_GET['redirectURL'] : '?action=Category';
// Kategorien für das Dropdown holen
$_isBrOnly = !is_wiki_redakteur() && is_wiki_betriebsrat();
if ($_isBrOnly) {
$_brCatIds = get_all_betriebsrat_accessible_category_ids();
if (empty($_brCatIds)) {
$catsQuery = "SELECT NULL LIMIT 0";
} else {
$_brCatIdList = implode(',', $_brCatIds);
$catsQuery = "
SELECT c.id, t.title
FROM knowledgecenter_categories_update AS c
LEFT JOIN knowledgecenter_category_translations AS t
ON t.category_id = c.id AND t.language_id = $languageId
WHERE c.id IN ($_brCatIdList)
ORDER BY t.title ASC
";
}
} else {
$catsQuery = "
SELECT c.id, t.title
FROM knowledgecenter_categories_update AS c
LEFT JOIN knowledgecenter_category_translations AS t
ON t.category_id = c.id AND t.language_id = $languageId
ORDER BY t.title ASC
";
}
$catsResult = mysqli_query($GLOBALS['mysql_con'], $catsQuery);
$allCategories = [];
while ($r = mysqli_fetch_assoc($catsResult)) {
$allCategories[] = $r;
}
?>
<div class="titlebar">
<div>
<a title="<?= $translation->get("back") ?>" href="?action=List">←</a>
<h1><?= $translation->get("post_list") ?> <span id="postCount"></span>
</h1>
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
<a href="?action=NewPost" class="post-action-btn green">+</a>
<?php } ?>
</div>
<div>
<select id="categoryFilter">
<option value=""><?= $translation->get("all_categories") ?></option>
<?php foreach ($allCategories as $cat): ?>
<option value="<?= $cat['id'] ?>">
<?= htmlspecialchars($cat['title'] ?: "ID:{$cat['id']}") ?>
</option>
<?php endforeach; ?>
</select>
<input type="search" id="searchQuery" placeholder="<?= $translation->get("search_placeholder") ?>" />
</div>
</div>
<div id="postListContainer" class="wiki-list">
</div>
<?php
$_brCategoryIds = $_isBrOnly ? ($_brCatIds ?? []) : null;
$_brCategoryIdsJson = json_encode($_brCategoryIds);
?>
<script>
var myRedirectURL = window.location.href;
var debounceTimer;
var brCategoryIds = <?= $_brCategoryIdsJson ?>;
function loadPostList() {
var searchQuery = $('#searchQuery').val();
var categoryId = $('#categoryFilter').val(); // neu
$.ajax({
url: '/module/knowledgecenter_update/Ajax/Ajax.php',
type: 'POST',
data: {
action: 'get_post_list',
language_code: '<?= $languageCode ?>',
language_id: '<?= $languageId ?>',
redirectURL: myRedirectURL,
query: searchQuery,
category_id: categoryId,
br_category_ids: brCategoryIds !== null ? JSON.stringify(brCategoryIds) : null
},
dataType: 'json',
beforeSend: function () { $('#overlayLoader').show(); },
complete: function () { $('#overlayLoader').hide(); },
success: function (response) {
if (response.success) {
$('#postListContainer').html(response.html);
var c = parseInt(response.count || 0, 10);
$('#postCount').text('(' + c + ')');
} else {
alert('Fehler: ' + response.error);
}
},
error: function (xhr, status, error) {
console.error('AJAX-Fehler:', error);
}
});
}
$(document).ready(function () {
loadPostList();
$('#searchQuery').on('input', function () {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(loadPostList, 300);
});
$('#categoryFilter').on('change', loadPostList);
});
</script>

View File

@@ -0,0 +1 @@
<iframe class="fullscreen" src="/intranet/de/wiki/?action=PostList&embedded=1" frameborder="0"></iframe>

View File

@@ -0,0 +1,59 @@
/* Drag-Handle Overlay für Quill Image Resize */
.ql-image-overlay {
box-sizing: border-box;
border: 1px dashed #2684ff;
pointer-events: none;
}
.ql-image-handle {
position: absolute;
width: 12px;
height: 12px;
background: #ffffff;
border: 2px solid #2684ff;
border-radius: 2px;
pointer-events: auto;
z-index: 10001;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
}
.ql-image-handle:hover {
background: #2684ff;
}
.ql-image-handle-nw {
left: -6px;
top: -6px;
cursor: nwse-resize;
}
.ql-image-handle-ne {
right: -6px;
top: -6px;
cursor: nesw-resize;
}
.ql-image-handle-sw {
left: -6px;
bottom: -6px;
cursor: nesw-resize;
}
.ql-image-handle-se {
right: -6px;
bottom: -6px;
cursor: nwse-resize;
}
/* Während des Drags Text-Selection global unterdrücken */
.ql-image-overlay.is-dragging,
.ql-image-overlay.is-dragging * {
user-select: none;
-webkit-user-select: none;
}
/* Im Editor selbst soll das Bild visuell ein leichtes Outline bekommen,
wenn es aktiv ist — aber CSS kann nicht aus dem Overlay zurückgreifen,
daher: ein dünner Schatten ist genug; der gestrichelte Rand am Overlay
markiert das aktive Bild klar genug. */

View File

@@ -0,0 +1,363 @@
/**
* Quill-Modul: Drag-Resize für Bilder im Editor.
*
* Aktivierung pro Editor-Instanz via:
* modules: { imageResize: true }
*
* Verhalten:
* - Click auf <img> im Editor → 4 Ecken-Handles als position:fixed-Overlay
* - mousedown/touchstart auf einem Handle startet den Drag
* - Drag aktualisiert width-Attribut + Inline-Style mit !important am <img>
* (Inline-Style ist nötig, weil die globale `img { max-width:100% }`-Regel
* im Intranet das HTML-width-Attribut überstimmt — Spezifität 0 verliert
* gegen jede Author-CSS-Regel auf <img>)
* - Aspect Ratio über CSS `height: auto` gehalten
*
* Persistenz-Pipeline:
* 1) ResizableImage-Blot (unten registriert) whitelisted alt/width/height in
* Quills formats/image-Override, damit die Attribute den Quill-Save-Pfad
* (`quill.update`) überleben.
* 2) Pro Quill-Instanz: Clipboard-Matcher für IMG + Monkey-Patch um
* `dangerouslyPasteHTML`. Die reparieren den Lade-Pfad, weil Quill
* 2.0.0-dev.3 beim HTML→Delta-Parse die Attribute trotz Blot droppt.
* Der Monkey-Patch reappliziert width-Attribut UND Inline-!important-Style
* am Editor-DOM (gleiches Pattern wie Live-Drag).
*
* Bewusst klein gehalten. Keine Abhängigkeit zu third-party Resize-Modulen,
* die für Quill 1.3.x geschrieben wurden und in 2.0.0-dev.3 unstet sind.
*/
(function (global) {
'use strict';
if (typeof Quill === 'undefined') {
console.error('[quill_resize] Quill ist nicht geladen.');
return;
}
// ResizableImage-Blot: Quill 2.0.0-dev.3' nativer formats/image strippt
// beim Re-Parse alle Attribute außer src. Dieser Blot whitelisted alt,
// width, height, damit die per Drag gesetzte Größe den Roundtrip überlebt.
//
// Quill 2.x' Blots sind ES6-Klassen — Vererbung MUSS via `class ... extends`
// erfolgen, nicht via Prototype-Chain (Image.apply würde TypeError werfen,
// weil ES6-Classes nicht ohne `new` aufgerufen werden können).
//
// try/catch: Falls Quill in einer Version vorliegt, die diese Override-API
// nicht unterstützt, kippt der Editor nicht — wir loggen und fallen
// stillschweigend auf den nativen Image-Blot zurück.
if (!global.__ResizableImageRegistered) {
try {
var Image = Quill.import('formats/image');
var ATTRS = ['alt', 'width', 'height'];
var ResizableImage = class extends Image {
static create(value) {
var src = value;
var extras = null;
if (value && typeof value === 'object') {
src = value.src || value.url || '';
extras = value;
}
var node = super.create(src);
if (extras) {
ATTRS.forEach(function (attr) {
if (extras[attr] !== undefined && extras[attr] !== null && extras[attr] !== '') {
node.setAttribute(attr, extras[attr]);
}
});
}
return node;
}
static formats(domNode) {
var base = (typeof super.formats === 'function') ? super.formats(domNode) : {};
ATTRS.forEach(function (attr) {
if (domNode.hasAttribute(attr)) {
base[attr] = domNode.getAttribute(attr);
}
});
return base;
}
format(name, value) {
if (ATTRS.indexOf(name) > -1) {
if (value) {
this.domNode.setAttribute(name, value);
} else {
this.domNode.removeAttribute(name);
}
} else {
super.format(name, value);
}
}
};
ResizableImage.blotName = 'image';
ResizableImage.tagName = 'IMG';
Quill.register(ResizableImage, true);
global.__ResizableImageRegistered = true;
global.ResizableImage = ResizableImage;
} catch (err) {
console.error('[quill_resize] ResizableImage-Blot konnte nicht registriert werden — fallback auf nativen Image-Blot. Resize-Größe geht beim Re-Edit verloren.', err);
}
}
var MIN_WIDTH = 50;
function ImageResize(quill, options) {
this.quill = quill;
this.editor = quill.root;
this.options = options || {};
this.activeImg = null;
this.overlay = null;
this.dragState = null;
// Methoden binden, damit removeEventListener funktioniert
this.onEditorClick = this.onEditorClick.bind(this);
this.onDocPointerDown = this.onDocPointerDown.bind(this);
this.onReposition = this.onReposition.bind(this);
this.onMove = this.onMove.bind(this);
this.onUp = this.onUp.bind(this);
this.editor.addEventListener('click', this.onEditorClick);
document.addEventListener('mousedown', this.onDocPointerDown, true);
document.addEventListener('touchstart', this.onDocPointerDown, true);
window.addEventListener('resize', this.onReposition);
window.addEventListener('scroll', this.onReposition, true);
// Bei Text-Changes Position anpassen (z.B. wenn Bild verschoben wird)
quill.on('text-change', this.onReposition);
// Quill 2.0.0-dev.3 strippt beim dangerouslyPasteHTML alle <img>-Attribute
// außer src. Reader rendert das gespeicherte HTML direkt — also korrekt;
// aber im Editor ist die Größe nach Re-Open weg. Zwei Schutzschichten:
//
// (a) Clipboard-Matcher: Beim HTML→Delta-Parse die width/height/alt
// als delta.attributes mitführen. Bei korrekt greifendem
// ResizableImage-Blot überlebt das den Render zurück ans DOM.
//
// (b) Monkey-Patch um dangerouslyPasteHTML: Falls (a) in dev.3 nicht
// durchgereicht wird (Embed-format-Hook ignoriert), reapplizieren
// wir die Attribute nach dem Paste direkt am Editor-DOM. Idempotent
// pro Quill-Instanz via Marker-Property.
try {
quill.clipboard.addMatcher('IMG', function (node, delta) {
if (!node || !delta || !delta.ops || !delta.ops.length) return delta;
var op = delta.ops[0];
if (!op || !op.insert) return delta;
var attrs = op.attributes || {};
['alt', 'width', 'height'].forEach(function (a) {
if (node.hasAttribute(a) && attrs[a] == null) {
attrs[a] = node.getAttribute(a);
}
});
if (Object.keys(attrs).length) op.attributes = attrs;
return delta;
});
} catch (e) {
console.warn('[quill_resize] Clipboard-Matcher Setup fehlgeschlagen', e);
}
if (!quill.__pasteHtmlPatched && quill.clipboard
&& typeof quill.clipboard.dangerouslyPasteHTML === 'function') {
var origPaste = quill.clipboard.dangerouslyPasteHTML;
var PASTE_ATTRS = ['alt', 'width', 'height'];
quill.clipboard.dangerouslyPasteHTML = function () {
var args = Array.prototype.slice.call(arguments);
var htmlArg = (typeof args[0] === 'string') ? args[0] : args[1];
var seq = [];
if (typeof htmlArg === 'string' && htmlArg.indexOf('<img') !== -1) {
var tmp = document.createElement('div');
tmp.innerHTML = htmlArg;
Array.prototype.forEach.call(tmp.querySelectorAll('img'), function (img) {
var entry = { src: img.getAttribute('src') || '', attrs: {} };
PASTE_ATTRS.forEach(function (a) {
if (img.hasAttribute(a)) entry.attrs[a] = img.getAttribute(a);
});
seq.push(entry);
});
}
var result = origPaste.apply(this, args);
if (seq.length) {
var domImgs = quill.root.querySelectorAll('img');
var ix = 0;
for (var di = 0; di < domImgs.length && ix < seq.length; di++) {
var dimg = domImgs[di];
var dsrc = dimg.getAttribute('src') || '';
while (ix < seq.length && seq[ix].src !== dsrc) ix++;
if (ix < seq.length && seq[ix].src === dsrc) {
var entryAttrs = seq[ix].attrs;
Object.keys(entryAttrs).forEach(function (k) {
if (!dimg.hasAttribute(k)) {
dimg.setAttribute(k, entryAttrs[k]);
}
});
// Inline-Style mit !important: das HTML-width-Attribut
// hat Spezifität 0 und wird im Intranet von der globalen
// mysyde.css-img-Regel überstimmt (oder von Quills internem
// Embed-Wrapping). Inline-!important gewinnt garantiert.
// Spiegelt das Verhalten von onMove (live-drag) wider.
if (entryAttrs.width) {
dimg.style.setProperty('width', entryAttrs.width + 'px', 'important');
dimg.style.setProperty('height', 'auto', 'important');
}
ix++;
}
}
}
return result;
};
quill.__pasteHtmlPatched = true;
}
}
ImageResize.prototype.onEditorClick = function (e) {
if (e.target && e.target.tagName === 'IMG' && this.editor.contains(e.target)) {
this.attachTo(e.target);
} else {
this.detach();
}
};
ImageResize.prototype.attachTo = function (img) {
if (this.activeImg === img && this.overlay) return;
this.detach();
this.activeImg = img;
this.overlay = this.buildOverlay();
document.body.appendChild(this.overlay);
this.reposition();
};
ImageResize.prototype.buildOverlay = function () {
var self = this;
var box = document.createElement('div');
box.className = 'ql-image-overlay';
['nw', 'ne', 'sw', 'se'].forEach(function (corner) {
var h = document.createElement('div');
h.className = 'ql-image-handle ql-image-handle-' + corner;
h.dataset.corner = corner;
// Corner explizit über Closure binden, nicht über e.currentTarget
// (currentTarget kann nach Listener-Return null sein, je nach Browser).
var startDrag = function (e) { self.onHandleDown(e, corner); };
h.addEventListener('mousedown', startDrag);
h.addEventListener('touchstart', startDrag, { passive: false });
box.appendChild(h);
});
return box;
};
ImageResize.prototype.reposition = function () {
if (!this.activeImg || !this.overlay) return;
var r = this.activeImg.getBoundingClientRect();
var s = this.overlay.style;
s.position = 'fixed';
s.left = r.left + 'px';
s.top = r.top + 'px';
s.width = r.width + 'px';
s.height = r.height + 'px';
// KEIN pointer-events:none hier — die Handles brauchen Klick-Empfang.
// Der gestrichelte Rand des Overlays liegt im CSS auf
// `.ql-image-overlay` mit pointer-events:none direkt im CSS.
s.zIndex = '10000';
};
ImageResize.prototype.onReposition = function () {
this.reposition();
};
ImageResize.prototype.onHandleDown = function (e, corner) {
if (!this.activeImg) return;
e.preventDefault();
e.stopPropagation();
var p = (e.touches && e.touches[0]) ? e.touches[0] : e;
var r = this.activeImg.getBoundingClientRect();
this.dragState = {
startX: p.clientX,
startY: p.clientY,
startW: r.width,
startH: r.height,
corner: corner
};
document.addEventListener('mousemove', this.onMove);
document.addEventListener('mouseup', this.onUp);
document.addEventListener('touchmove', this.onMove, { passive: false });
document.addEventListener('touchend', this.onUp);
document.addEventListener('touchcancel', this.onUp);
};
ImageResize.prototype.onMove = function (e) {
if (!this.dragState || !this.activeImg) return;
if (e.cancelable) e.preventDefault();
var p = (e.touches && e.touches[0]) ? e.touches[0] : e;
var dx = p.clientX - this.dragState.startX;
// Links liegende Handles invertieren das Vorzeichen:
// rechts ziehen → dx positiv → image schrumpft (left handle)
if (this.dragState.corner === 'nw' || this.dragState.corner === 'sw') {
dx = -dx;
}
var editorR = this.editor.getBoundingClientRect();
var maxW = Math.max(MIN_WIDTH, editorR.width - 8);
var newW = Math.max(MIN_WIDTH, Math.min(maxW, this.dragState.startW + dx));
newW = Math.round(newW);
// width sowohl als HTML-Attribut als auch als Inline-Style setzen.
// Inline-Style ist nötig, weil im Intranet eine CSS-Regel mit höherer
// Spezifität als das Attribut existiert (an dieser Stelle nicht zu
// finden ohne Live-DevTools). Inline-Spezifität (1000) gewinnt gegen
// alle nicht-!important CSS-Regeln. setProperty mit 'important' als
// Fallback, falls eine !important-Regel im Spiel ist.
var widthPx = newW + 'px';
this.activeImg.setAttribute('width', String(newW));
this.activeImg.style.setProperty('width', widthPx, 'important');
this.activeImg.style.setProperty('height', 'auto', 'important');
this.activeImg.removeAttribute('height');
this.reposition();
};
ImageResize.prototype.onUp = function () {
document.removeEventListener('mousemove', this.onMove);
document.removeEventListener('mouseup', this.onUp);
document.removeEventListener('touchmove', this.onMove);
document.removeEventListener('touchend', this.onUp);
document.removeEventListener('touchcancel', this.onUp);
if (this.dragState) {
this.dragState = null;
try { this.quill.update('user'); } catch (e) {}
}
};
ImageResize.prototype.onDocPointerDown = function (e) {
if (!this.activeImg) return;
if (this.overlay && this.overlay.contains(e.target)) return;
if (e.target === this.activeImg) return;
this.detach();
};
ImageResize.prototype.detach = function () {
if (this.overlay && this.overlay.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
}
this.overlay = null;
this.activeImg = null;
this.dragState = null;
};
Quill.register('modules/imageResize', ImageResize);
global.QuillImageResize = ImageResize;
})(window);

View File

@@ -0,0 +1,108 @@
/**
* Geteilte Toolbar-Presets für Quill-Editoren (Wiki, Forum).
*
* Jeder Preset ist eine Factory-Funktion, die ein frisches Array
* liefert. Grund: Quill kann das Modules-Objekt während der Init
* mutieren — bei mehreren Editor-Instanzen auf einer Seite (z.B.
* Wiki mit Sprach-Tabs) würden sie sich sonst gegenseitig
* verändern.
*
* Verwendung pro Editor:
*
* container: window.QuillToolbarPresets.full()
*
* Bewusst nicht enthalten: `code` (inline) — nur `code-block`,
* weil Multi-Line-Code die häufige Nutzung ist.
*/
(function (global) {
'use strict';
global.QuillToolbarPresets = {
// Vollständige Toolbar — Goldstandard für Wiki und Forum
full: function () {
return [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ script: 'sub' }, { script: 'super' }],
[{ color: [] }, { background: [] }],
[{ align: [] }],
[
{ list: 'ordered' }, { list: 'bullet' },
{ indent: '-1' }, { indent: '+1' }
],
['link', 'image', 'video'],
['code-block', 'table'],
['clean']
];
},
// Compact-Toolbar — wie full(), aber OHNE Image und Video.
// Genutzt in Modulen, die kein Image-Backend haben (Tasks, Products).
compact: function () {
return [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ script: 'sub' }, { script: 'super' }],
[{ color: [] }, { background: [] }],
[{ align: [] }],
[
{ list: 'ordered' }, { list: 'bullet' },
{ indent: '-1' }, { indent: '+1' }
],
['link'],
['code-block', 'table'],
['clean']
];
},
// Better-Table-Modul-Konfiguration. Identisch über alle Editoren —
// deutsche Operations-Menu-Labels und der Hintergrundfarben-Picker
// für Tabellenzellen.
betterTableConfig: function () {
return {
operationMenu: {
items: {
insertRowUp: { text: 'Zeile oberhalb einfügen' },
insertRowDown: { text: 'Zeile unterhalb einfügen' },
insertColumnLeft: { text: 'Spalte links einfügen' },
insertColumnRight: { text: 'Spalte rechts einfügen' },
mergeCells: { text: 'Zellen zusammenführen' },
unmergeCells: { text: 'Zellen teilen' },
deleteRow: { text: 'Zeile löschen' },
deleteColumn: { text: 'Spalte löschen' },
deleteTable: { text: 'Tabelle löschen' }
},
color: {
colors: [
'#f8f9fa', '#e9ecef', '#dee2e6', '#ced4da',
'#adb5bd', '#6c757d',
'green', 'red', 'yellow', 'blue',
'white', 'black', 'orange'
],
text: 'Hintergrundfarbe'
}
}
};
}
// Vorlage für künftige Erweiterungen — derzeit nicht aktiv:
//
// compact: function () {
// // wie full, aber ohne 'table' — z.B. für Comment-Felder
// var t = this.full();
// var idx = t.findIndex(function (row) { return row.indexOf('table') > -1; });
// if (idx > -1) t[idx] = ['code-block'];
// return t;
// },
//
// minimal: function () {
// return [
// [{ header: [2, 3, false] }],
// ['bold', 'italic', 'underline'],
// [{ list: 'ordered' }, { list: 'bullet' }],
// ['link'],
// ['clean']
// ];
// }
};
})(window);

99
module/tasks/README.md Normal file
View File

@@ -0,0 +1,99 @@
# Task-Cert Modul
Aufgabenverwaltung mit Zielgruppen-Zuweisung, Erledigungsmethoden, Freigabeprozess, Zertifikaten und Eskalationen.
## Schnellstart
```bash
# 1. Schema initialisieren (Erstinstallation)
mysql -u root -p intranet < sql/init.sql
# 2. Migrationen anwenden (bestehende Installation)
mysql -u root -p intranet < sql/migrations/2026-03-17_add_task_responsible.sql
mysql -u root -p intranet < sql/migrations/2026-03-18_add_grace_period.sql
mysql -u root -p intranet < sql/migrations/2026-03-18_add_form_field_prefill.sql
# 3. Cronjobs einrichten (siehe docs/cronjobs.md)
```
## Verzeichnisstruktur
```
tasks/
bootstrap.php DI-Container, URL-Mapping, Asset-Helper
pages/
admin/ Admin-Views (task_list, task_form, task_detail, ...)
user/ User-Views (my_tasks, task_detail, Widgets)
actions/ View-Controller (Datenaufbereitung pro Seite)
endpoints/
admin/ Schreibende Admin-Endpunkte (POST/CLI)
user/ Schreibende User-Endpunkte (POST)
service/ Business-Logik (10 Services)
repo/ DB-Zugriff (Repository-Pattern)
lib/ Utility-Klassen (Auth, DB, Request, View, ...)
sql/
init.sql Vollständiges Schema (15 Tabellen)
migrations/ Inkrementelle Migrationen
assets/
css/ task_cert.css, task_cert_admin.css, task_cert_user.css
js/ task_cert_admin_form_wizard.js
vendor/ MultiSelect-Komponente
cli/ Cron-CLI-Skripte
logs/ Cron-Logs, Lockfiles, Heartbeat-Dateien
doc/ Architektur- und Lifecycle-Dokumentation
docs/ Betriebsdokumentation (Cronjobs)
```
## Dokumentation
| Dokument | Inhalt |
|---|---|
| [Architektur & Lifecycle](doc/task-cert-module-lifecycle.md) | Services, Datenmodell, Status-Flows, Troubleshooting |
| [Cronjobs](docs/cronjobs.md) | Einrichtung, Intervalle, CLI-Befehle |
| [Testfälle](doc/task-cert-test-cases.md) | 17 Szenarien mit erwarteten Ergebnissen |
## Services
| Service | Verantwortung |
|---|---|
| `TaskDefinitionService` | Aufgaben CRUD, Zyklen-Reset, Audit-Log |
| `AssignmentResolverService` | Zuweisungen materialisieren & synchronisieren |
| `CycleCalculatorService` | Zyklusberechnung (Kalender- & Erledigungsbasis) |
| `CompletionService` | User-Einreichungen, Methodenabschluss, Completion-Guard |
| `ApprovalService` | Manuelle Freigabe/Ablehnung |
| `CertificateService` | Zertifikate ausstellen/widerrufen |
| `EscalationService` | Eskalationsstufen, Grace-Period-Logik |
| `TaskMonitoringService` | Admin-Dashboard KPIs |
| `TaskCategoryService` | Kategorienverwaltung |
| `TaskLookupService` | Stammdaten-Lookups (Kontakte, Mandanten, ...) |
## Wichtige Konzepte
### Rhythmus-Basis
- **Kalenderbasiert** (`calendar`): Feste Zyklen ab Startdatum (z.B. jeden Monat)
- **Erledigungsbasiert** (`completion`): Nächster Zyklus individuell nach Erledigung
### Karenzzeit (Grace Period)
Neue Zuweisungen erhalten eine `grace_until`-Frist. Die Eskalation greift erst nach `max(due_at, grace_until)`. Konfigurierbar pro Aufgabe in der Erinnerungsrichtlinie (`grace_days`, Default: 7).
### Formular-Vorbefüllung (Prefill)
Textfelder in Formularen können mit Kontaktdaten des Nutzers vorbefüllt werden. Konfigurierbar pro Feld via `prefill_column`. Erlaubte Spalten definiert in `lib/ContactPrefillColumns.php`.
### Completion-Guard
Abgeschlossene Assignments sind gesperrt — erneutes Einreichen wird sowohl im Backend (Exception) als auch im Frontend (disabled Buttons) verhindert.
### Sofortige Folgezyklus-Erstellung
Bei erledigungsbasierten Aufgaben wird der nächste Zyklus direkt nach Completion erstellt (nicht erst beim nächsten Cron-Lauf). Deduplizierung über `createIfNotExists` + Unique-Key.
### Cron-Heartbeat
Erfolgreiche Cron-Läufe schreiben Timestamps nach `logs/heartbeat/*.json`. Status sichtbar am Ende der Admin-Aufgabenliste. Klasse: `lib/CronHeartbeat.php`.
## Cronjobs (Kurzfassung)
```crontab
*/15 * * * * php /path/to/tasks/endpoints/admin/materialize_assignments.php
*/30 * * * * php /path/to/tasks/endpoints/admin/recalculate_escalations.php --apply
0 2 * * * php /path/to/tasks/endpoints/admin/sync_assignments.php --apply
```
Details: [docs/cronjobs.md](docs/cronjobs.md)

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
function task_cert_admin_approval_queue_action(): array
{
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
return [
'error' => 'Keine Berechtigung für die Aufgaben-Administration.',
'submissions' => [],
];
}
$taskIdFilter = TaskCertRequest::queryInt('task_id', 0);
$searchQuery = TaskCertRequest::queryString('q', '');
$services = task_cert_services();
$submissions = $services['submissionRepo']->listPendingApprovals($taskIdFilter, $searchQuery);
return [
'error' => null,
'submissions' => $submissions,
'task_id_filter' => $taskIdFilter,
'search_query' => $searchQuery,
'csrf_token' => TaskCertCsrf::token(),
];
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
function task_cert_admin_escalation_dashboard_action(): array
{
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
return [
'error' => 'Keine Berechtigung für die Aufgaben-Administration.',
'assignments' => [],
];
}
$services = task_cert_services();
$didRecalculate = false;
$searchQuery = TaskCertRequest::queryString('q', '');
if (TaskCertRequest::queryInt('recalc', 0) === 1) {
$services['escalationService']->recalculateEscalations();
$didRecalculate = true;
}
$assignments = $services['assignmentRepo']->listForAdmin(0, '', $searchQuery);
$assignments = array_values(array_filter(
$assignments,
static function (array $row): bool {
return (int) ($row['escalation_level'] ?? 0) > 0;
}
));
return [
'error' => null,
'assignments' => $assignments,
'did_recalculate' => $didRecalculate,
'search_query' => $searchQuery,
'csrf_token' => TaskCertCsrf::token(),
];
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
function task_cert_admin_task_categories_action(): array
{
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
return [
'error' => 'Keine Berechtigung für die Aufgaben-Administration.',
'categories' => [],
];
}
$searchQuery = TaskCertRequest::queryString('q', '');
$stateFilter = TaskCertRequest::queryString('state', 'active');
if (!in_array($stateFilter, ['active', 'inactive', 'all'], true)) {
$stateFilter = 'active';
}
$services = task_cert_services();
$categoryService = $services['taskCategoryService'] ?? null;
if (!$categoryService instanceof TaskCategoryService) {
return [
'error' => 'Kategorie-Service nicht verfügbar.',
'categories' => [],
];
}
$categories = $categoryService->listForAdmin($searchQuery, $stateFilter);
$currentQuery = ['state' => $stateFilter];
if ($searchQuery !== '') {
$currentQuery['q'] = $searchQuery;
}
$flashSuccess = null;
$flashError = null;
if (TaskCertRequest::queryInt('cat_done', 0) === 1) {
$action = TaskCertRequest::queryString('cat_action', '');
$flashSuccessMap = [
'category_create' => 'Kategorie wurde angelegt.',
'category_update' => 'Kategorie wurde aktualisiert.',
'category_activate' => 'Kategorie wurde aktiviert.',
'category_deactivate' => 'Kategorie wurde deaktiviert.',
'category_delete' => 'Kategorie wurde gelöscht.',
];
$flashSuccess = $flashSuccessMap[$action] ?? 'Änderung wurde gespeichert.';
}
$errorParam = TaskCertRequest::queryString('cat_error', '');
if ($errorParam !== '') {
$flashError = $errorParam;
}
return [
'error' => null,
'categories' => $categories,
'search_query' => $searchQuery,
'state_filter' => $stateFilter,
'current_url' => task_cert_view_url('admin.task_categories', $currentQuery),
'flash_success' => $flashSuccess,
'flash_error' => $flashError,
'csrf_token' => TaskCertCsrf::token(),
];
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
function task_cert_admin_task_detail_action(): array
{
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
return [
'error' => 'Keine Berechtigung für die Aufgaben-Administration.',
];
}
$taskId = TaskCertRequest::queryInt('id', 0);
if ($taskId <= 0) {
return [
'error' => 'Ungültige Aufgaben-ID.',
];
}
$services = task_cert_services();
$task = $services['taskDefinitionService']->getTaskDetail($taskId);
if ($task === null) {
return [
'error' => 'Aufgabe nicht gefunden.',
];
}
$monitoringService = $services['taskMonitoringService'] ?? null;
if (!$monitoringService instanceof TaskMonitoringService) {
return [
'error' => 'Monitoring-Service konnte nicht initialisiert werden.',
];
}
$dashboard = $monitoringService->buildDashboard($task, [
'cycle_key' => TaskCertRequest::queryString('cycle_key', ''),
'tab' => TaskCertRequest::queryString('tab', 'overview'),
]);
return [
'error' => null,
'task' => $task,
'dashboard' => $dashboard,
'csrf_token' => TaskCertCsrf::token(),
];
}

View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
function task_cert_admin_task_form_action(): array
{
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
return [
'error' => 'Keine Berechtigung für die Aufgaben-Administration.',
];
}
$taskId = TaskCertRequest::queryInt('id', 0);
$services = task_cert_services();
$task = null;
$rules = [
'methods' => [],
'target_rules' => [],
'target_overrides' => [],
'reminder_policy' => [
'base_mode' => 'due_date',
'first_escalation_days' => 0,
'step_days' => 7,
'max_level' => 3,
'active' => 1,
],
];
if ($taskId > 0) {
$task = $services['taskDefinitionService']->getTaskDetail($taskId);
if ($task === null) {
return [
'error' => 'Aufgabe nicht gefunden.',
];
}
$rules = $task['rules'] ?? $rules;
}
$methodConfig = task_cert_normalize_method_config($rules['methods'] ?? []);
$targetSelection = task_cert_normalize_target_selection($rules['target_rules'] ?? [], $rules['target_overrides'] ?? []);
$targetSelection['responsible_contact_ids'] = is_array($rules['responsible_contact_ids'] ?? null)
? array_values(array_unique(array_map('intval', $rules['responsible_contact_ids'])))
: [];
$languageId = (int) ($GLOBALS['language']['id'] ?? 1);
$lookupService = $services['lookupService'] ?? null;
$lookup = ($lookupService instanceof TaskLookupService)
? $lookupService->getAdminFormLookups($languageId)
: [
'task_categories' => [],
'mandants' => [],
'departments' => [],
'roles' => [],
'contacts' => [],
'knowledge_posts' => [],
];
$taskCategoryId = (int) (($task['task_category_id'] ?? 0));
if ($taskCategoryId > 0) {
$taskCategories = is_array($lookup['task_categories'] ?? null) ? $lookup['task_categories'] : [];
$hasSelectedCategory = false;
foreach ($taskCategories as $category) {
if ((int) ($category['id'] ?? 0) === $taskCategoryId) {
$hasSelectedCategory = true;
break;
}
}
if (!$hasSelectedCategory) {
$taskCategories[] = [
'id' => $taskCategoryId,
'name' => 'Kategorie #' . $taskCategoryId . ' (nicht gefunden)',
'slug' => '',
'sort_order' => 999999,
'active' => 0,
];
}
$lookup['task_categories'] = $taskCategories;
}
return [
'error' => null,
'task' => $task,
'rules' => $rules,
'method_config' => $methodConfig,
'target_selection' => $targetSelection,
'lookup' => $lookup,
'csrf_token' => TaskCertCsrf::token(),
];
}
function task_cert_normalize_method_config(array $methods): array
{
$types = TaskCertConstants::METHOD_TYPES;
$enabled = array_fill_keys($types, 0);
$config = [
'enabled' => $enabled,
'knowledge_post_ids' => [],
'form_fields' => [],
'quiz_questions' => [],
];
foreach ($methods as $method) {
$type = (string) ($method['method_type'] ?? '');
if (!array_key_exists($type, $config['enabled'])) {
continue;
}
$config['enabled'][$type] = ((int) ($method['active'] ?? 1) === 1) ? 1 : 0;
if ($type === 'knowledge_read') {
foreach (($method['knowledge_posts'] ?? []) as $post) {
$postId = (int) ($post['knowledge_post_id'] ?? 0);
if ($postId > 0) {
$config['knowledge_post_ids'][] = $postId;
}
}
}
if ($type === 'form_submit') {
$fields = is_array($method['form_fields'] ?? null) ? $method['form_fields'] : [];
foreach ($fields as $field) {
$options = [];
foreach ((array) ($field['options'] ?? []) as $option) {
if (is_array($option)) {
$value = trim((string) ($option['value'] ?? $option['label'] ?? ''));
if ($value !== '') {
$options[] = $value;
}
} else {
$value = trim((string) $option);
if ($value !== '') {
$options[] = $value;
}
}
}
$config['form_fields'][] = [
'field_key' => (string) ($field['field_key'] ?? ''),
'label' => (string) ($field['label'] ?? ''),
'field_type' => (string) ($field['field_type'] ?? 'text'),
'is_required' => (int) ($field['is_required'] ?? 0),
'placeholder' => (string) ($field['placeholder'] ?? ''),
'prefill_column' => (string) ($field['prefill_column'] ?? ''),
'options_text' => implode(', ', $options),
];
}
}
if ($type === 'quiz') {
foreach ((array) ($method['quiz_questions'] ?? []) as $question) {
$normalizedQuestion = [
'question_text' => (string) ($question['question_text'] ?? ''),
'option_a' => '',
'option_b' => '',
'option_c' => '',
'option_d' => '',
'correct_option' => 'a',
];
$options = array_values((array) ($question['options'] ?? []));
$letters = ['a', 'b', 'c', 'd'];
foreach ($letters as $index => $letter) {
if (!isset($options[$index])) {
continue;
}
$option = $options[$index];
$normalizedQuestion['option_' . $letter] = (string) ($option['option_text'] ?? '');
if ((int) ($option['is_correct'] ?? 0) === 1) {
$normalizedQuestion['correct_option'] = $letter;
}
}
$config['quiz_questions'][] = $normalizedQuestion;
}
}
}
$config['knowledge_post_ids'] = array_values(array_unique(array_map('intval', $config['knowledge_post_ids'])));
return $config;
}
function task_cert_normalize_target_selection(array $targetRules, array $targetOverrides): array
{
$selection = [
'mandant_ids' => [],
'department_ids' => [],
'role_ids' => [],
'einricht_ids' => [],
'fachbereich_ids' => [],
'override_include_contact_ids' => [],
'override_exclude_contact_ids' => [],
'override_include_reason' => '',
'override_exclude_reason' => '',
];
foreach ($targetRules as $rule) {
$mandantId = (int) ($rule['main_mandant_id'] ?? 0);
$departmentId = (int) ($rule['main_department_id'] ?? 0);
$roleId = (int) ($rule['main_role_id'] ?? 0);
$einrichtId = (int) ($rule['main_einricht_id'] ?? 0);
$fachbereichId = (int) ($rule['main_bereich_id'] ?? 0);
if ($mandantId > 0) {
$selection['mandant_ids'][] = $mandantId;
}
if ($departmentId > 0) {
$selection['department_ids'][] = $departmentId;
}
if ($roleId > 0) {
$selection['role_ids'][] = $roleId;
}
if ($einrichtId > 0) {
$selection['einricht_ids'][] = $einrichtId;
}
if ($fachbereichId > 0) {
$selection['fachbereich_ids'][] = $fachbereichId;
}
}
foreach ($targetOverrides as $override) {
$contactId = (int) ($override['main_contact_id'] ?? 0);
if ($contactId <= 0) {
continue;
}
$type = (string) ($override['override_type'] ?? '');
if ($type === 'include') {
$selection['override_include_contact_ids'][] = $contactId;
if ($selection['override_include_reason'] === '') {
$selection['override_include_reason'] = (string) ($override['reason'] ?? '');
}
}
if ($type === 'exclude') {
$selection['override_exclude_contact_ids'][] = $contactId;
if ($selection['override_exclude_reason'] === '') {
$selection['override_exclude_reason'] = (string) ($override['reason'] ?? '');
}
}
}
$arrayKeys = [
'mandant_ids',
'department_ids',
'role_ids',
'einricht_ids',
'fachbereich_ids',
'override_include_contact_ids',
'override_exclude_contact_ids',
];
foreach ($arrayKeys as $key) {
$values = is_array($selection[$key] ?? null) ? $selection[$key] : [];
$selection[$key] = array_values(array_unique(array_map('intval', $values)));
}
return $selection;
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
function task_cert_admin_task_list_action(): array
{
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
return [
'error' => 'Keine Berechtigung für die Aufgaben-Administration.',
'tasks' => [],
];
}
$services = task_cert_services();
$searchQuery = TaskCertRequest::queryString('q', '');
$categoryIdFilter = TaskCertRequest::queryInt('category_id', 0);
if ($categoryIdFilter < 0) {
$categoryIdFilter = 0;
}
$tasks = $services['taskDefinitionService']->listTasksForAdmin($searchQuery, $categoryIdFilter);
$categoryFilter = null;
if ($categoryIdFilter > 0) {
$categoryService = $services['taskCategoryService'] ?? null;
if ($categoryService instanceof TaskCategoryService) {
$categoryFilter = $categoryService->getById($categoryIdFilter);
}
}
$myTasks = [];
$contactId = $auth->contactId();
if ($contactId > 0) {
$myTasks = $services['taskDefinitionService']->listTasksForResponsible($contactId);
}
$lookupRepo = $services['lookupRepo'] ?? null;
$selectedContactId = TaskCertRequest::queryInt('contact_id', 0);
$contactAssignments = [];
$selectedContactName = '';
if ($selectedContactId > 0 && $lookupRepo instanceof TaskLookupRepository) {
$contact = $lookupRepo->getContactById($selectedContactId);
if (is_array($contact)) {
$selectedContactName = trim((string) ($contact['name'] ?? ''));
if ($selectedContactName === '') {
$selectedContactName = trim((string) ($contact['email'] ?? 'Kontakt #' . $selectedContactId));
}
}
if ($selectedContactName === '') {
$selectedContactName = 'Kontakt #' . $selectedContactId;
}
$contactAssignments = $services['assignmentRepo']->listByContactId($selectedContactId);
}
return [
'error' => null,
'tasks' => $tasks,
'my_tasks' => $myTasks,
'search_query' => $searchQuery,
'category_id_filter' => $categoryIdFilter,
'category_filter' => $categoryFilter,
'selected_contact_id' => $selectedContactId,
'selected_contact_name' => $selectedContactName,
'contact_assignments' => $contactAssignments,
'csrf_token' => TaskCertCsrf::token(),
];
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
function task_cert_user_escalation_overlay_widget_action(): array
{
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->isLoggedIn()) {
return [
'error' => null,
'viewer_contact_id' => 0,
'escalated_count' => 0,
'my_tasks_url' => task_cert_view_url('user.my_tasks'),
];
}
$services = task_cert_services();
$assignments = $services['assignmentRepo']->listForUser($auth->contactId());
$escalatedCount = 0;
foreach ($assignments as $assignment) {
$status = (string) ($assignment['status'] ?? '');
if (in_array($status, ['completed', 'expired'], true)) {
continue;
}
if ((int) ($assignment['escalation_level'] ?? 0) > 0) {
$escalatedCount++;
}
}
return [
'error' => null,
'viewer_contact_id' => $auth->contactId(),
'escalated_count' => $escalatedCount,
'my_tasks_url' => task_cert_view_url('user.my_tasks'),
];
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
function task_cert_user_my_tasks_action(): array
{
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->isLoggedIn()) {
return [
'error' => 'Nicht eingeloggt.',
'assignments' => [],
'certificate_summary_by_task' => [],
];
}
$services = task_cert_services();
$assignments = $services['assignmentRepo']->listForUser($auth->contactId());
$taskIds = [];
foreach ($assignments as $assignment) {
$taskId = (int) ($assignment['task_id'] ?? 0);
if ($taskId > 0) {
$taskIds[] = $taskId;
}
}
return [
'error' => null,
'assignments' => $assignments,
'certificate_summary_by_task' => $services['certificateService']->getUserTaskCertificateSummary($auth->contactId(), $taskIds),
'csrf_token' => TaskCertCsrf::token(),
];
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
function task_cert_user_overdue_widget_action(): array
{
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->isLoggedIn()) {
return [
'error' => 'Nicht eingeloggt.',
'assignments' => [],
'has_more' => false,
'total_count' => 0,
'limit' => 0,
];
}
$limit = max(1, min(50, TaskCertRequest::queryInt('limit', 8)));
$services = task_cert_services();
$assignments = $services['assignmentRepo']->listForUser($auth->contactId());
$now = new DateTimeImmutable();
$attentionAssignments = [];
foreach ($assignments as $assignment) {
$status = (string) ($assignment['status'] ?? '');
if (in_array($status, ['completed', 'expired'], true)) {
continue;
}
$isEscalated = (int) ($assignment['escalation_level'] ?? 0) > 0;
$isOverdueStatus = $status === 'overdue';
$isOverdueByDate = false;
$dueAtRaw = trim((string) ($assignment['due_at'] ?? ''));
if ($dueAtRaw !== '') {
try {
$isOverdueByDate = (new DateTimeImmutable($dueAtRaw)) < $now;
} catch (Throwable $throwable) {
$isOverdueByDate = false;
}
}
if (!$isEscalated && !$isOverdueStatus && !$isOverdueByDate) {
continue;
}
$assignment['is_escalated'] = $isEscalated ? 1 : 0;
$assignment['is_overdue_by_date'] = $isOverdueByDate ? 1 : 0;
$attentionAssignments[] = $assignment;
}
usort($attentionAssignments, static function (array $left, array $right): int {
$leftEscalation = (int) ($left['escalation_level'] ?? 0);
$rightEscalation = (int) ($right['escalation_level'] ?? 0);
if ($leftEscalation !== $rightEscalation) {
return $rightEscalation <=> $leftEscalation;
}
$leftOverdue = (int) ($left['is_overdue_by_date'] ?? 0);
$rightOverdue = (int) ($right['is_overdue_by_date'] ?? 0);
if ($leftOverdue !== $rightOverdue) {
return $rightOverdue <=> $leftOverdue;
}
$leftDue = trim((string) ($left['due_at'] ?? ''));
$rightDue = trim((string) ($right['due_at'] ?? ''));
if ($leftDue === '' && $rightDue !== '') {
return 1;
}
if ($leftDue !== '' && $rightDue === '') {
return -1;
}
if ($leftDue !== '' && $rightDue !== '' && $leftDue !== $rightDue) {
return strcmp($leftDue, $rightDue);
}
return ((int) ($right['id'] ?? 0)) <=> ((int) ($left['id'] ?? 0));
});
$totalCount = count($attentionAssignments);
$hasMore = $totalCount > $limit;
if ($hasMore) {
$attentionAssignments = array_slice($attentionAssignments, 0, $limit);
}
return [
'error' => null,
'assignments' => $attentionAssignments,
'has_more' => $hasMore,
'total_count' => $totalCount,
'limit' => $limit,
];
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
function task_cert_user_summary_widget_action(): array
{
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->isLoggedIn()) {
return [
'error' => null,
'open_count' => 0,
'overdue_count' => 0,
'escalated_count' => 0,
];
}
$services = task_cert_services();
$assignments = $services['assignmentRepo']->listForUser($auth->contactId());
$now = new DateTimeImmutable();
$openCount = 0;
$overdueCount = 0;
$escalatedCount = 0;
foreach ($assignments as $assignment) {
$status = (string) ($assignment['status'] ?? '');
if (in_array($status, ['completed', 'expired'], true)) {
continue;
}
$openCount++;
$isOverdue = $status === 'overdue';
if (!$isOverdue) {
$dueAtRaw = trim((string) ($assignment['due_at'] ?? ''));
if ($dueAtRaw !== '') {
try {
$isOverdue = (new DateTimeImmutable($dueAtRaw)) < $now;
} catch (Throwable $throwable) {
$isOverdue = false;
}
}
}
if ($isOverdue) {
$overdueCount++;
}
if ((int) ($assignment['escalation_level'] ?? 0) > 0) {
$escalatedCount++;
}
}
return [
'error' => null,
'open_count' => $openCount,
'overdue_count' => $overdueCount,
'escalated_count' => $escalatedCount,
];
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
function task_cert_user_task_detail_action(): array
{
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->isLoggedIn()) {
return [
'error' => 'Nicht eingeloggt.',
];
}
$assignmentId = TaskCertRequest::queryInt('assignment_id', 0);
if ($assignmentId <= 0) {
return [
'error' => 'Ungültige Assignment-ID.',
];
}
$services = task_cert_services();
$assignment = $services['assignmentRepo']->findById($assignmentId);
if ($assignment === null) {
return [
'error' => 'Assignment nicht gefunden.',
];
}
if ((int) $assignment['main_contact_id'] !== $auth->contactId()) {
return [
'error' => 'Keine Berechtigung für dieses Assignment.',
];
}
$task = $services['definitionRepo']->findById((int) $assignment['task_id']);
if ($task === null) {
return [
'error' => 'Aufgabe nicht gefunden.',
];
}
$methods = $services['ruleRepo']->getMethodsByTask((int) $assignment['task_id']);
$methods = array_values(array_filter($methods, static function (array $method): bool {
return (int) ($method['active'] ?? 1) === 1;
}));
$submissions = $services['submissionRepo']->listByAssignment($assignmentId);
$postIds = [];
foreach ($methods as $method) {
if ((string) ($method['method_type'] ?? '') !== 'knowledge_read') {
continue;
}
foreach ((array) ($method['knowledge_posts'] ?? []) as $post) {
$postId = (int) ($post['knowledge_post_id'] ?? 0);
if ($postId > 0) {
$postIds[] = $postId;
}
}
}
$knowledgePostTitleMap = [];
$lookupService = $services['lookupService'] ?? null;
if ($lookupService instanceof TaskLookupService) {
$languageId = (int) ($GLOBALS['language']['id'] ?? 1);
$knowledgePostTitleMap = $lookupService->getKnowledgePostTitleMapByIds($languageId, $postIds);
}
foreach ($methods as &$method) {
if ((string) ($method['method_type'] ?? '') !== 'knowledge_read') {
continue;
}
foreach ((array) ($method['knowledge_posts'] ?? []) as &$post) {
$postId = (int) ($post['knowledge_post_id'] ?? 0);
$post['title'] = $knowledgePostTitleMap[$postId] ?? ('Beitrag #' . $postId);
}
unset($post);
}
unset($method);
$responsibles = $services['ruleRepo']->getResponsiblesWithContactData((int) $assignment['task_id']);
$neededPrefillColumns = [];
foreach ($methods as $m) {
foreach ((array) ($m['form_fields'] ?? []) as $ff) {
$col = trim((string) ($ff['prefill_column'] ?? ''));
if ($col !== '' && ContactPrefillColumns::isValid($col)) {
$neededPrefillColumns[$col] = true;
}
}
}
$contactData = [];
if ($neededPrefillColumns !== []) {
try {
$contactRow = TaskCertDb::fetchOne(
'SELECT * FROM main_contact WHERE id = ? LIMIT 1',
'i',
[$auth->contactId()]
);
if (is_array($contactRow)) {
$contactData = $contactRow;
}
} catch (Throwable $e) {
$contactData = [];
}
}
return [
'error' => null,
'assignment' => $assignment,
'task' => $task,
'methods' => $methods,
'submissions' => $submissions,
'task_certificates_history' => $services['certificateService']->listUserTaskCertificates(
$auth->contactId(),
(int) $assignment['task_id']
),
'responsibles' => $responsibles,
'contact_data' => $contactData,
'csrf_token' => TaskCertCsrf::token(),
];
}

View File

@@ -0,0 +1,289 @@
/* task_cert baseline styles using monolith design tokens with fallbacks */
.tasks-cert main {
background: var(--bg-white);
}
.admin-task-list main {
background: var(--bg-white);
}
.my-tasks main {
background: var(--bg-white);
}
.task-cert input[type="checkbox"] {
width: auto;
margin: 0 0 0px 0px;
height: auto;
}
.task-cert input[type="radio"] {
width: auto;
margin: 0 0 0px 0px;
height: auto;
}
.task-cert {
--tc-font-family: var(--ff-body, var(--ff-primary, sans-serif));
--tc-font-size: var(--fs-400, 0.875rem);
--tc-font-size-h2: var(--fs-700, 1.125rem);
--tc-font-size-h3: var(--fs-650, 1rem);
--tc-radius: var(--br, 8px);
--tc-border-color: var(--border, #e5e5e5);
--tc-bg: var(--blur_elevated, var(--bg-white, #ffffff));
--tc-bg-soft: var(--clr-light, #f5f5f5);
--tc-text: var(--fc-primary, #272727);
--tc-muted: var(--fc-dark-8, #666666);
--tc-accent: var(--blue-accent, #1b66b3);
--tc-error: var(--error, #cc0000);
--tc-error-border: #f0b4b4;
--tc-error-bg: #fff1f1;
--tc-success: #0f7a0f;
--tc-success-border: #a5dca5;
--tc-success-bg: #edf9ed;
--tc-warning: #9a6400;
--tc-warning-border: #f0cf8c;
--tc-warning-bg: #fff8e8;
font-family: var(--tc-font-family);
font-size: var(--tc-font-size);
color: var(--tc-text);
background: var(--tc-bg);
}
.task-cert,
.task-cert * {
box-sizing: border-box;
}
.task-cert h2,
.task-cert h3,
.task-cert h4 {
margin: 0 0 10px;
line-height: 1.3;
color: var(--tc-text);
}
.task-cert h2 {
font-size: var(--tc-font-size-h2);
}
.task-cert h3,
.task-cert h4 {
font-size: var(--tc-font-size-h3);
}
.task-cert a {
color: var(--tc-accent);
text-decoration: none;
}
.task-cert a:hover {
text-decoration: underline;
}
.task-cert .task-cert-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.task-cert .task-cert-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.task-cert .task-cert-toolbar-left,
.task-cert .task-cert-toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.task-cert .task-cert-toolbar-right {
margin-left: auto;
}
.task-cert .task-cert-links {
margin-top: 10px;
}
.task-cert .task-cert-block {
border: 1px solid var(--tc-border-color);
border-radius: var(--tc-radius);
background: var(--tc-bg-soft);
padding: 10px;
margin-bottom: 10px;
}
.task-cert .task-cert-error {
border: 1px solid var(--tc-error-border);
border-left: 4px solid var(--tc-error);
border-radius: var(--tc-radius);
background: var(--tc-error-bg);
color: #7a1212;
padding: 10px;
margin-bottom: 10px;
}
.task-cert .task-cert-success {
border: 1px solid var(--tc-success-border);
border-left: 4px solid var(--tc-success);
border-radius: var(--tc-radius);
background: var(--tc-success-bg);
color: #145f14;
padding: 10px;
margin-bottom: 10px;
}
.task-cert .task-cert-warning {
border: 1px solid var(--tc-warning-border);
border-left: 4px solid var(--tc-warning);
border-radius: var(--tc-radius);
background: var(--tc-warning-bg);
color: #7b5200;
padding: 10px;
margin-bottom: 10px;
}
.task-cert .btn,
.task-cert button {
display: inline-block;
border: 1px solid var(--tc-border-color);
border-radius: calc(var(--tc-radius) - 2px);
padding: 6px 10px;
background: var(--tc-bg);
color: var(--tc-text);
font-size: inherit;
cursor: pointer;
}
.task-cert .btn:hover,
.task-cert button:hover {
background: var(--tc-bg-soft);
text-decoration: none;
}
.task-cert .btn.task-cert-btn-danger,
.task-cert button.task-cert-btn-danger {
background: #d93a3a;
border-color: #d93a3a;
color: #fff;
}
.task-cert .btn.task-cert-btn-danger:hover,
.task-cert .btn.task-cert-btn-danger:focus-visible,
.task-cert button.task-cert-btn-danger:hover,
.task-cert button.task-cert-btn-danger:focus-visible {
background: #bb2f2f;
border-color: #bb2f2f;
color: #fff;
}
.task-cert .btn.task-cert-btn-success,
.task-cert button.task-cert-btn-success {
background: #14b814;
border-color: #14b814;
color: #fff;
}
.task-cert .btn.task-cert-btn-success:hover,
.task-cert .btn.task-cert-btn-success:focus-visible,
.task-cert button.task-cert-btn-success:hover,
.task-cert button.task-cert-btn-success:focus-visible {
background: #119e11;
border-color: #119e11;
color: #fff;
}
.task-cert .btn:disabled,
.task-cert button:disabled {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
}
.task-cert form {
margin: 0;
}
.task-cert label {
display: inline-block;
margin: 4px 0;
}
.task-cert input[type="text"],
.task-cert input[type="number"],
.task-cert input[type="date"],
.task-cert select,
.task-cert textarea {
width: 100%;
border: 1px solid var(--tc-border-color);
border-radius: calc(var(--tc-radius) - 2px);
background: var(--tc-bg);
color: var(--tc-text);
padding: 8px;
}
.task-cert textarea {
min-height: 88px;
}
.task-cert .task-cert-table-wrap {
overflow-x: auto;
}
.task-cert .task-cert-table {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
}
.task-cert .task-cert-table th,
.task-cert .task-cert-table td {
border: 1px solid var(--tc-border-color);
padding: 8px;
text-align: left;
vertical-align: top;
}
.task-cert .task-cert-table th {
background: var(--tc-bg-soft);
}
.task-cert pre {
margin: 0;
border: 1px solid var(--tc-border-color);
border-radius: var(--tc-radius);
background: var(--tc-bg);
color: var(--tc-text);
padding: 10px;
overflow: auto;
max-height: 280px;
}
.task-cert .task-cert-method {
border: 1px solid var(--tc-border-color);
border-radius: var(--tc-radius);
padding: 10px;
margin: 10px 0;
background: var(--tc-bg);
}
.task-cert .task-cert-quiz-question {
border-left: 3px solid var(--tc-accent);
padding: 8px;
margin: 8px 0;
background: var(--tc-bg-soft);
}
.task-cert .task-cert-hint {
margin: 8px 0;
color: var(--tc-muted);
font-size: var(--fs-300, 0.8125rem);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,971 @@
.task-cert.task-cert-user-overdue-widget {
border: 1px solid var(--border, #e5e5e5);
border-radius: var(--br, 8px);
background: var(--blur_elevated, var(--bg-white, #fff));
padding: 0px;
}
.task-cert-user-widget-head.widget_header h2 {
margin: 0;
}
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-head h2 {
font-size: var(--fs-500, 0.9375rem);
}
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 26px;
border-radius: 999px;
border: 1px solid var(--border, #e5e5e5);
background: var(--clr-light, #f5f5f5);
padding: 0 8px;
font-weight: var(--fw-bold, 700);
}
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-list {
display: grid;
gap: 8px;
padding: 1rem;
}
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item {
border: 1px solid var(--border, #e5e5e5);
border-left: 3px solid var(--border, #e5e5e5);
border-radius: var(--br, 8px);
background: var(--blur_elevated, var(--bg-white, #fff));
color: inherit;
text-decoration: none;
padding: 8px 10px;
display: grid;
gap: 6px;
}
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item:hover,
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item:focus-visible {
text-decoration: none;
border-color: var(--blue-accent, #1b66b3);
}
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item.is-overdue {
border-left-color: #d93a3a;
}
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item.is-escalated {
border-left-color: #c08a00;
}
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item.is-overdue.is-escalated {
border-left-color: #d93a3a;
}
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item-head strong {
min-width: 0;
line-height: 1.35;
}
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
color: var(--fc-dark-8, #666);
font-size: var(--fs-300, 0.8125rem);
}
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid #c08a00;
background: #fff7e1;
color: #815c00;
padding: 1px 8px;
}
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-empty {
margin: 0;
}
.task-cert.task-cert-user-summary-widget {
margin: 0 0 1rem;
}
.task-cert.task-cert-user-summary-widget .task-cert-user-summary-bar {
display: block;
border: 1px solid var(--border, #e5e5e5);
border-left: 4px solid #14b814;
border-radius: var(--br, 8px);
background: var(--blur_elevated, var(--bg-white, #fff));
color: var(--fc-dark, #222);
padding: 7px 10px;
font-size: 0.86rem;
line-height: 1.3;
text-align: center;
text-decoration: none;
}
.task-cert.task-cert-user-summary-widget .task-cert-user-summary-bar:hover,
.task-cert.task-cert-user-summary-widget .task-cert-user-summary-bar:focus-visible {
border-color: var(--blue-accent, #1b66b3);
text-decoration: none;
}
body.task-cert-user-escalation-overlay-open {
overflow: hidden;
}
.task-cert.task-cert-user-escalation-overlay-widget {
margin: 0;
}
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay {
position: fixed;
inset: 0;
z-index: 2147483000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay[hidden] {
display: none !important;
}
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
}
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay-panel {
position: relative;
z-index: 1;
width: min(100%, 480px);
border: 1px solid var(--border, #e5e5e5);
border-left: 4px solid #d93a3a;
border-radius: var(--br, 8px);
background: var(--blur_elevated, var(--bg-white, #fff));
color: var(--fc-dark, #222);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.2);
padding: 14px;
}
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay-panel h3 {
margin: 0 0 8px;
}
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay-panel p {
margin: 0;
line-height: 1.4;
}
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay-actions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay-actions .btn {
flex: 1 1 auto;
text-align: center;
}
/* ── my-tasks: filter bar ── */
.task-cert.task-cert-user-my-tasks .task-cert-user-filter-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.task-cert.task-cert-user-my-tasks .task-cert-user-filter-bar select {
min-width: 180px;
border: 1px solid var(--tc-border-color, var(--border, #e5e5e5));
border-radius: calc(var(--tc-radius, var(--br, 8px)) - 2px);
background: var(--tc-bg, var(--blur_elevated, var(--bg-white, #fff)));
color: var(--tc-text, var(--fc-primary, #272727));
padding: 7px 8px;
font-size: var(--fs-400, 0.875rem);
}
.task-cert.task-cert-user-my-tasks .task-cert-user-filter-reset {
font-size: var(--fs-300, 0.8125rem);
color: var(--fc-dark-8, #666);
white-space: nowrap;
}
.task-cert-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ── my-tasks: category group ── */
.task-cert.task-cert-user-my-tasks .task-cert-user-category-group {
margin-bottom: 25px;
}
.task-cert.task-cert-user-my-tasks .task-cert-user-category-group:last-child {
margin-bottom: 0;
}
.task-cert.task-cert-user-my-tasks .task-cert-user-category-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 0 8px;
border-bottom: 2px solid var(--border, #e5e5e5);
background: var(--bg-light);
padding: 7px 8px;
}
.task-cert.task-cert-user-my-tasks .task-cert-user-category-head h3 {
margin: 0;
font-size: var(--fs-500, 0.9375rem);
}
.task-cert.task-cert-user-my-tasks .task-cert-user-category-head span {
color: var(--fc-dark-8, #666);
font-size: var(--fs-300, 0.8125rem);
}
.task-cert.task-cert-user-my-tasks .task-cert-user-task-cards {
display: grid;
grid-template-columns: 1fr;
gap: 0px;
}
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card {
border-bottom: 1px solid var(--border, #e5e5e5);
background: var(--blur_elevated, var(--bg-white, #fff));
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
color: inherit;
text-decoration: none;
}
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card:hover, .task-cert.task-cert-user-my-tasks .task-cert-user-task-card:focus-visible {
box-shadow: 0 0 0 1px rgba(27, 102, 179, 0.12);
text-decoration: none;
border-left: 1px solid var(--blue-accent);
}
a.task-cert-user-task-card.status-completed strong:before {
background: #ecffec;
border: 1px solid #4bc132 !important;
box-shadow: inset 0px 0px 0px 2px #31ac31;
}
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card-head strong:before {
content: '';
width: 13px;
height: 13px;
border: 1px solid black;
border-radius: 999px;
display: inline-block;
margin-right: 4px;
position: relative;
top: 2px;
}
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card-head {
display: flex;
justify-content: space-between;
gap: 6px;
flex-wrap: wrap;
}
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card-head strong {
line-height: 1.35;
min-width: 0;
}
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card-head .task-cert-user-status-badge {
justify-self: flex-start;
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card-meta {
margin-top: auto;
font-size: var(--fs-300, 0.8125rem);
color: var(--fc-dark-8, #666);
}
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card.status-overdue {
border-color: #d93a3a;
}
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card.is-escalated {
border-color: #c08a00;
}
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card.is-overdue-date {
border: 1px dashed #d93a3a;
background: #fffafa;
padding-inline: 10px;
}
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card.is-overdue-date:hover,
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card.is-overdue-date:focus-visible {
border-color: #d93a3a;
box-shadow: 0 0 0 1px rgba(217, 58, 58, 0.2);
}
.task-cert.task-cert-user-my-tasks .task-cert-user-status-badge {
padding: 1px 6px;
font-size: 0.74rem;
line-height: 1.2;
}
.task-cert .task-cert-user-status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid var(--border, #e5e5e5);
background: var(--blur_elevated, var(--bg-white, #fff));
padding: 2px 8px;
white-space: nowrap;
}
.task-cert .task-cert-user-status-badge.status-overdue {
border-color: #d93a3a;
color: #b52828;
background: #ffecec;
}
.task-cert .task-cert-user-status-badge.status-completed {
border-color: #14b814;
color: #0c7f0c;
background: #ecffec;
}
.task-cert .task-cert-user-status-badge.status-approval_pending {
border-color: #c08a00;
color: #815c00;
background: #fff7e1;
}
.task-cert .task-cert-user-status-badge.status-valid {
border-color: #14b814;
color: #0c7f0c;
background: #ecffec;
}
.task-cert .task-cert-user-status-badge.status-expired,
.task-cert .task-cert-user-status-badge.status-revoked {
border-color: #d93a3a;
color: #b52828;
background: #ffecec;
}
.task-cert.task-cert-user-detail .task-cert-user-detail-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.task-cert.task-cert-user-detail .task-cert-user-detail-header h2 {
margin: 0;
}
.task-cert.task-cert-user-detail .task-cert-user-title-row {
display: flex;
align-items: center;
gap: 10px;
}
.task-cert.task-cert-user-detail .task-cert-user-title-row h2 {
margin: 0;
font-size: 1.625rem;
line-height: 1.2;
}
.task-cert.task-cert-user-detail .task-cert-user-title-row .task-cert-back-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid var(--border, #e5e5e5);
background: var(--bg-white, #fff);
text-decoration: none;
font-size: 1rem;
line-height: 1;
}
.task-cert.task-cert-user-detail .task-cert-user-title-row .task-cert-back-link:hover,
.task-cert.task-cert-user-detail .task-cert-user-title-row .task-cert-back-link:focus-visible {
border-color: var(--blue-accent, #1b66b3);
text-decoration: none;
}
.task-cert.task-cert-user-detail .task-cert-user-status-compact {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
padding: 8px 10px;
border: 1px solid var(--border, #e5e5e5);
border-radius: var(--br, 8px);
background: var(--blur_elevated, var(--bg-white, #fff));
margin-bottom: 10px;
}
.task-cert.task-cert-user-detail .task-cert-user-status-due {
font-size: var(--fs-300, 0.8125rem);
color: var(--fc-dark-8, #666);
}
.task-cert.task-cert-user-detail .task-cert-user-status-due.is-overdue {
color: #b52828;
font-weight: 600;
}
.task-cert.task-cert-user-detail .task-cert-user-status-steps {
font-size: var(--fs-300, 0.8125rem);
color: var(--fc-dark-8, #666);
margin-left: auto;
}
.task-cert.task-cert-user-detail .task-cert-user-layout {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.task-cert.task-cert-user-detail .task-cert-user-main {
min-width: 0;
width: inherit;
}
.task-cert.task-cert-user-detail .task-cert-user-aside {
min-width: 0;
align-self: start;
}
.task-cert.task-cert-user-detail .task-cert-user-aside .task-cert-block {
position: static;
}
.task-cert.task-cert-user-detail .task-cert-user-aside .task-cert-block + .task-cert-block {
margin-top: 10px;
}
/* Rich text description content */
.task-cert .task-cert-description-content p {
margin: 0 0 0.6em;
line-height: 1.6;
}
.task-cert .task-cert-description-content p:last-child {
margin-bottom: 0;
}
.task-cert .task-cert-description-content h2 {
font-size: 1.25em;
margin: 0.8em 0 0.4em;
}
.task-cert .task-cert-description-content h3 {
font-size: 1.1em;
margin: 0.6em 0 0.3em;
}
.task-cert .task-cert-description-content ul,
.task-cert .task-cert-description-content ol {
margin: 0.4em 0 0.6em;
padding-left: 1.8em;
}
.task-cert .task-cert-description-content li {
margin-bottom: 0.2em;
}
.task-cert .task-cert-description-content a {
color: var(--tc-primary, #1a73e8);
text-decoration: underline;
}
.task-cert.task-cert-user-detail .task-cert-user-step {
border: 1px solid var(--border, #e5e5e5);
border-radius: var(--br, 8px);
background: var(--blur_elevated, var(--bg-white, #fff));
padding: 10px;
margin-bottom: 10px;
}
.task-cert.task-cert-user-detail .task-cert-user-step-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
margin-bottom: 8px;
}
.task-cert.task-cert-user-detail .task-cert-user-method-state {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid var(--border, #e5e5e5);
background: var(--clr-light, #f5f5f5);
padding: 2px 8px;
white-space: nowrap;
}
.task-cert.task-cert-user-detail .task-cert-user-method-state.state-done {
border-color: #14b814;
color: #0c7f0c;
background: #ecffec;
}
.task-cert.task-cert-user-detail .task-cert-user-method-state.state-pending {
border-color: #c08a00;
color: #815c00;
background: #fff7e1;
}
.task-cert.task-cert-user-detail .task-cert-user-method-state.state-retry {
border-color: #d93a3a;
color: #b52828;
background: #ffecec;
}
.task-cert.task-cert-user-detail .task-cert-user-stack {
display: grid;
gap: 8px;
margin: 8px 0 10px;
}
.task-cert.task-cert-user-detail .task-cert-user-read-posts {
display: grid;
gap: 10px;
margin: 8px 0 10px;
}
.task-cert.task-cert-user-detail .task-cert-user-read-post {
border: 1px solid var(--border, #e5e5e5);
border-radius: var(--br, 8px);
background: var(--blur_elevated, var(--bg-white, #fff));
padding: 10px;
display: grid;
gap: 8px;
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-head strong {
line-height: 1.35;
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-head a {
font-size: var(--fs-300, 0.8125rem);
white-space: normal;
}
.task-cert.task-cert-user-detail .task-cert-user-read-badge {
display: inline-block;
font-size: var(--fs-300, 0.8125rem);
font-weight: 600;
color: #2e7d32;
background: #e8f5e9;
border: 1px solid #a5d6a7;
border-radius: var(--br, 8px);
padding: 1px 8px;
white-space: nowrap;
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-details {
border: 1px solid var(--border, #e5e5e5);
border-radius: var(--br, 8px);
background: var(--bg-white, #fff);
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-details > summary {
list-style: none;
cursor: pointer;
padding: 8px 10px;
font-weight: 600;
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-details > summary::-webkit-details-marker {
display: none;
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-details > summary::before {
content: "\25B6";
display: inline-block;
margin-right: 6px;
font-size: 0.75rem;
transform: translateY(-1px);
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-details[open] > summary::before {
content: "\25BC";
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-content {
border-top: 1px solid var(--border, #e5e5e5);
padding: 10px;
display: grid;
gap: 8px;
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-iframe-wrap {
overflow: hidden;
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-iframe-wrap iframe {
display: block;
width: 100%;
min-height: 360px;
border: 0;
}
.task-cert.task-cert-user-detail .task-cert-user-checkbox-row {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0;
}
.task-cert.task-cert-user-detail .task-cert-user-checkbox-row small {
color: var(--fc-dark-8, #666);
}
.task-cert.task-cert-user-detail .task-cert-quiz-question {
display: grid;
gap: 10px;
}
.task-cert.task-cert-user-detail .task-cert-user-quiz-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.task-cert.task-cert-user-detail .task-cert-user-quiz-option {
border: 1px solid var(--border, #e5e5e5);
border-radius: var(--br, 8px);
background: var(--blur_elevated, var(--bg-white, #fff));
padding: 8px 10px;
}
/* ── submission history ── */
.task-cert.task-cert-user-detail .task-cert-user-submissions-history {
display: grid;
gap: 12px;
}
.task-cert.task-cert-user-detail .task-cert-user-submission-group {
display: grid;
gap: 6px;
}
.task-cert.task-cert-user-detail .task-cert-user-submission-card {
border: 1px solid var(--border, #e5e5e5);
border-radius: var(--br, 8px);
background: var(--blur_elevated, var(--bg-white, #fff));
padding: 8px 10px;
display: grid;
gap: 4px;
}
.task-cert.task-cert-user-detail .task-cert-user-submission-card.status-rejected {
border-color: #d93a3a;
background: #fff8f8;
}
.task-cert.task-cert-user-detail .task-cert-user-submission-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.task-cert.task-cert-user-detail .task-cert-user-submission-card-date {
font-size: var(--fs-300, 0.8125rem);
color: var(--fc-dark-8, #666);
}
/* ── older submissions (collapsible) ── */
.task-cert.task-cert-user-detail .task-cert-user-older-submissions {
margin-top: 2px;
}
.task-cert.task-cert-user-detail .task-cert-user-older-submissions > summary {
list-style: none;
cursor: pointer;
font-size: var(--fs-300, 0.8125rem);
color: var(--fc-dark-8, #666);
font-weight: 500;
}
.task-cert.task-cert-user-detail .task-cert-user-older-submissions > summary::-webkit-details-marker {
display: none;
}
.task-cert.task-cert-user-detail .task-cert-user-older-submissions > summary::before {
content: "\25B6";
display: inline-block;
margin-right: 4px;
font-size: 0.65rem;
transform: translateY(-1px);
}
.task-cert.task-cert-user-detail .task-cert-user-older-submissions[open] > summary::before {
content: "\25BC";
}
.task-cert.task-cert-user-detail .task-cert-user-older-submissions-list {
display: grid;
gap: 6px;
margin-top: 6px;
}
.task-cert.task-cert-user-detail .task-cert-user-older-submissions-list .task-cert-user-submission-card {
opacity: 0.75;
}
/* ── submission details (collapsible answers) ── */
.task-cert.task-cert-user-detail .task-cert-user-submission-details {
margin-top: 2px;
}
.task-cert.task-cert-user-detail .task-cert-user-submission-details > summary {
list-style: none;
cursor: pointer;
font-size: var(--fs-300, 0.8125rem);
color: var(--blue-accent, #1b66b3);
font-weight: 500;
}
.task-cert.task-cert-user-detail .task-cert-user-submission-details > summary::-webkit-details-marker {
display: none;
}
.task-cert.task-cert-user-detail .task-cert-user-submission-details > summary::before {
content: "\25B6";
display: inline-block;
margin-right: 4px;
font-size: 0.65rem;
transform: translateY(-1px);
}
.task-cert.task-cert-user-detail .task-cert-user-submission-details[open] > summary::before {
content: "\25BC";
}
.task-cert.task-cert-user-detail .task-cert-user-submission-answers {
margin: 6px 0 0;
display: grid;
gap: 4px;
}
.task-cert.task-cert-user-detail .task-cert-user-submission-answer-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
font-size: var(--fs-300, 0.8125rem);
padding: 3px 0;
border-bottom: 1px solid var(--border-light, #f0f0f0);
}
.task-cert.task-cert-user-detail .task-cert-user-submission-answer-row dt {
font-weight: 600;
color: var(--fc-dark-8, #666);
}
.task-cert.task-cert-user-detail .task-cert-user-submission-answer-row dd {
margin: 0;
color: var(--fc-dark, #333);
word-break: break-word;
}
.task-cert.task-cert-user-detail .task-cert-user-certificate-cards {
display: grid;
gap: 8px;
}
.task-cert.task-cert-user-detail .task-cert-user-certificate-card {
border: 1px solid var(--border, #e5e5e5);
border-left: 4px solid #14b814;
border-radius: var(--br, 8px);
background: var(--blur_elevated, var(--bg-white, #fff));
padding: 8px 10px;
display: grid;
gap: 5px;
}
.task-cert.task-cert-user-detail .task-cert-user-certificate-card.status-expired,
.task-cert.task-cert-user-detail .task-cert-user-certificate-card.status-revoked {
border-left-color: #d93a3a;
}
.task-cert.task-cert-user-detail .task-cert-user-certificate-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.task-cert.task-cert-user-detail .task-cert-user-certificate-card-code {
display: grid;
gap: 2px;
}
.task-cert.task-cert-user-detail .task-cert-user-certificate-card-code strong {
font-size: 0.92rem;
line-height: 1.2;
color: var(--fc-dark, #222);
}
.task-cert.task-cert-user-detail .task-cert-user-certificate-card-code small {
display: inline-block;
max-width: 260px;
font-size: 0.72rem;
line-height: 1.3;
color: var(--fc-dark-8, #666);
overflow-wrap: anywhere;
word-break: break-word;
}
.task-cert.task-cert-user-detail .task-cert-user-certificate-card-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 12px;
font-size: 0.74rem;
line-height: 1.25;
color: var(--fc-dark-8, #666);
}
.task-cert.task-cert-user-detail .task-cert-user-certificate-card-meta span {
white-space: nowrap;
}
.task-cert.task-cert-user-detail .task-cert-method form > div {
margin: 8px 0;
}
.task-cert.task-cert-user-detail small {
color: var(--fc-dark-8, #666666);
}
/* ── user detail: responsibles ── */
.task-cert.task-cert-user-detail .task-cert-user-responsibles {
display: grid;
gap: 8px;
}
.task-cert.task-cert-user-detail .task-cert-user-responsible-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.task-cert.task-cert-user-detail .task-cert-user-responsible-item strong {
line-height: 1.35;
}
.task-cert.task-cert-user-detail .task-cert-user-responsible-item a {
font-size: var(--fs-300, 0.8125rem);
color: var(--blue-accent, #1b66b3);
word-break: break-all;
}
.task-cert.task-cert-user-my-tasks .task-cert-table td,
.task-cert.task-cert-user-detail .task-cert-table td {
vertical-align: middle;
}
@media (max-width: 768px) {
.task-cert.task-cert-user-detail .task-cert-user-layout {
grid-template-columns: 1fr;
}
.task-cert.task-cert-user-detail .task-cert-user-detail-header {
align-items: flex-start;
}
.task-cert.task-cert-user-detail .task-cert-user-title-row h2 {
font-size: 1.375rem;
}
.task-cert.task-cert-user-detail .task-cert-user-step-head {
flex-direction: column;
align-items: flex-start;
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-head {
flex-direction: column;
align-items: flex-start;
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-head a {
white-space: normal;
}
.task-cert.task-cert-user-detail .task-cert-user-read-post-iframe-wrap iframe {
min-height: 280px;
}
.task-cert.task-cert-user-detail .task-cert-user-quiz-options {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,219 @@
(function () {
'use strict';
var root = document.querySelector('.task-cert-admin-monitoring');
if (!root) { return; }
var csrfToken = root.getAttribute('data-csrf-token') || '';
var resetAssignmentUrl = root.getAttribute('data-reset-assignment-url') || '';
var resetCycleUrl = root.getAttribute('data-reset-cycle-url') || '';
var approvalUrl = root.getAttribute('data-approval-url') || '';
var taskId = parseInt(root.getAttribute('data-task-id') || '0', 10);
// ── Toast ─────────────────────────────────────────────────────
var toastContainer = document.getElementById('task-cert-toast-container');
function showToast(message, type) {
if (!toastContainer) { return; }
var cls = type === 'error' ? 'task-cert-error' : 'task-cert-success';
var toast = document.createElement('div');
toast.className = 'task-cert-toast ' + cls;
toast.textContent = String(message || '');
toast.addEventListener('click', function () {
removeToast(toast);
});
toastContainer.appendChild(toast);
// Trigger fade-in on next frame
requestAnimationFrame(function () {
toast.classList.add('is-visible');
});
var autoHideMs = type === 'error' ? 7000 : 4000;
setTimeout(function () {
removeToast(toast);
}, autoHideMs);
}
function removeToast(toast) {
if (!toast || !toast.parentNode) { return; }
toast.classList.remove('is-visible');
setTimeout(function () {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 250);
}
// Expose globally so other admin scripts (mark_completed) can reuse
window.TaskCertAdminActions = window.TaskCertAdminActions || {};
window.TaskCertAdminActions.showToast = showToast;
// ── Fetch-Helper ──────────────────────────────────────────────
function postJson(url, body) {
return fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function (res) {
return res.json().then(function (data) {
return { ok: res.ok, status: res.status, data: data };
}).catch(function () {
return { ok: false, status: res.status, data: { success: false, error: 'Ungültige Server-Antwort' } };
});
});
}
function reloadWithDelay() {
setTimeout(function () {
location.reload();
}, 800);
}
function setButtonBusy(btn, busy, busyLabel) {
if (!btn) { return; }
if (busy) {
btn._originalLabel = btn.textContent;
btn.textContent = busyLabel || 'Wird verarbeitet...';
btn.disabled = true;
} else {
if (btn._originalLabel) { btn.textContent = btn._originalLabel; }
btn.disabled = false;
}
}
// ── Action: Reset Assignment ──────────────────────────────────
function handleResetAssignment(btn) {
var assignmentId = parseInt(btn.getAttribute('data-assignment-id') || '0', 10);
if (!assignmentId || !taskId || !resetAssignmentUrl) { return; }
if (!window.confirm('Einreichungen und Zertifikat für diesen Zeitraum wirklich zurücksetzen?')) {
return;
}
setButtonBusy(btn, true, 'Wird zurückgesetzt...');
postJson(resetAssignmentUrl, {
csrf_token: csrfToken,
task_id: taskId,
assignment_id: assignmentId
}).then(function (result) {
if (result.data && result.data.success) {
showToast('Zeitraum wurde zurückgesetzt.', 'success');
reloadWithDelay();
} else {
setButtonBusy(btn, false);
showToast((result.data && result.data.error) || 'Fehler beim Zurücksetzen.', 'error');
}
}).catch(function (err) {
setButtonBusy(btn, false);
showToast('Netzwerkfehler: ' + (err && err.message ? err.message : 'Unbekannt'), 'error');
});
}
// ── Action: Reset Cycle ───────────────────────────────────────
function handleResetCycle(btn) {
var btnTaskId = parseInt(btn.getAttribute('data-task-id') || '0', 10) || taskId;
if (!btnTaskId || !resetCycleUrl) { return; }
if (!window.confirm('Wirklich ALLE Einreichungen und Zertifikate im aktuellen Zyklus zurücksetzen? Diese Aktion betrifft alle Mitarbeiter in diesem Zeitraum.')) {
return;
}
setButtonBusy(btn, true, 'Wird zurückgesetzt...');
postJson(resetCycleUrl, {
csrf_token: csrfToken,
task_id: btnTaskId
}).then(function (result) {
if (result.data && result.data.success) {
var stats = (result.data && result.data.stats) || {};
var n = parseInt(stats.assignment_count || 0, 10);
showToast('Zyklus wurde zurückgesetzt (' + n + ' Zuweisungen).', 'success');
reloadWithDelay();
} else {
setButtonBusy(btn, false);
showToast((result.data && result.data.error) || 'Fehler beim Zurücksetzen.', 'error');
}
}).catch(function (err) {
setButtonBusy(btn, false);
showToast('Netzwerkfehler: ' + (err && err.message ? err.message : 'Unbekannt'), 'error');
});
}
// ── Action: Approval ──────────────────────────────────────────
function handleApproval(btn) {
var wrapper = btn.closest('.task-cert-inline-approval-form');
if (!wrapper) { return; }
var submissionId = parseInt(wrapper.getAttribute('data-submission-id') || '0', 10);
var decision = btn.getAttribute('data-decision') || '';
if (!submissionId || !decision || !approvalUrl) { return; }
var noteEl = wrapper.querySelector('.task-cert-approval-note');
var note = noteEl ? noteEl.value.trim() : '';
if (decision === 'reject' && !window.confirm('Einreichung wirklich ablehnen?')) {
return;
}
// Disable both buttons in this row
var siblings = wrapper.querySelectorAll('.task-cert-btn-approval');
for (var i = 0; i < siblings.length; i++) {
siblings[i].disabled = true;
}
setButtonBusy(btn, true, decision === 'approve' ? 'Freigabe...' : 'Ablehnen...');
postJson(approvalUrl, {
csrf_token: csrfToken,
submission_id: submissionId,
decision: decision,
note: note
}).then(function (result) {
if (result.data && result.data.success) {
var msg = decision === 'approve' ? 'Einreichung freigegeben.' : 'Einreichung abgelehnt.';
showToast(msg, 'success');
reloadWithDelay();
} else {
for (var j = 0; j < siblings.length; j++) { siblings[j].disabled = false; }
setButtonBusy(btn, false);
showToast((result.data && result.data.error) || 'Fehler bei der Bearbeitung.', 'error');
}
}).catch(function (err) {
for (var k = 0; k < siblings.length; k++) { siblings[k].disabled = false; }
setButtonBusy(btn, false);
showToast('Netzwerkfehler: ' + (err && err.message ? err.message : 'Unbekannt'), 'error');
});
}
// ── Event-Delegation ──────────────────────────────────────────
document.addEventListener('click', function (e) {
var resetAssignBtn = e.target.closest('.task-cert-btn-reset-assignment');
if (resetAssignBtn) {
e.preventDefault();
handleResetAssignment(resetAssignBtn);
return;
}
var resetCycleBtn = e.target.closest('.task-cert-btn-reset-cycle');
if (resetCycleBtn) {
e.preventDefault();
handleResetCycle(resetCycleBtn);
return;
}
var approvalBtn = e.target.closest('.task-cert-btn-approval');
if (approvalBtn) {
e.preventDefault();
handleApproval(approvalBtn);
return;
}
});
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,292 @@
(function () {
'use strict';
var dialog = document.getElementById('task-cert-mark-completed-dialog');
var config = document.getElementById('task-cert-mark-completed-config');
if (!dialog || !config) { return; }
var methodsUrl = config.getAttribute('data-methods-url') || '';
var submitUrl = config.getAttribute('data-submit-url') || '';
var taskId = parseInt(config.getAttribute('data-task-id') || '0', 10);
if (!methodsUrl || !submitUrl || !taskId) { return; }
var METHOD_LABELS = {
knowledge_read: 'Wissensbestätigung',
form_submit: 'Formular',
quiz: 'Quiz',
manual_approval: 'Manuelle Freigabe'
};
function esc(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(String(str == null ? '' : str)));
return div.innerHTML;
}
function buildMethodHtml(method) {
var id = method.id;
var type = method.method_type;
var label = METHOD_LABELS[type] || type;
var required = method.is_required ? '' : ' <span class="task-cert-hint">(optional)</span>';
var fulfilled = method.already_fulfilled;
var html = '<fieldset class="task-cert-dialog-method' + (fulfilled ? ' is-fulfilled' : '') + '">';
html += '<legend>' + esc(label) + required;
if (fulfilled) {
html += ' <span class="task-cert-admin-fulfilled-badge">Bereits erledigt</span>';
}
html += '</legend>';
if (fulfilled) {
html += '<p class="task-cert-hint">Wird übersprungen.</p>';
} else if (type === 'knowledge_read') {
html += '<p class="task-cert-hint">Alle ' + method.knowledge_post_count + ' Beiträge werden automatisch als gelesen markiert.</p>';
} else if (type === 'quiz') {
html += '<p class="task-cert-hint">Quiz wird automatisch als bestanden gewertet (' + method.quiz_question_count + ' Fragen, volle Punktzahl).</p>';
} else if (type === 'manual_approval') {
html += '<p class="task-cert-hint">Manuelle Freigabe wird automatisch erteilt.</p>';
} else if (type === 'form_submit') {
var fields = method.form_fields || [];
if (fields.length === 0) {
html += '<p class="task-cert-hint">Keine Formularfelder definiert.</p>';
} else {
html += '<div class="task-cert-dialog-fields">';
for (var i = 0; i < fields.length; i++) {
html += buildFieldHtml(id, fields[i]);
}
html += '</div>';
}
}
html += '</fieldset>';
return html;
}
function buildFieldHtml(methodId, field) {
var key = field.field_key;
var label = field.label || key;
var type = field.field_type;
var req = field.is_required ? ' <span class="task-cert-hint">(Pflicht)</span>' : '';
var placeholder = esc(field.placeholder || '');
var name = 'method__' + methodId + '__' + key;
var inputId = 'mc-field-' + methodId + '-' + key;
var html = '<div class="task-cert-dialog-field">';
html += '<label for="' + inputId + '">' + esc(label) + req + '</label>';
if (type === 'textarea') {
html += '<textarea name="' + name + '" id="' + inputId + '" rows="3" placeholder="' + placeholder + '"></textarea>';
} else if (type === 'select' && field.options.length > 0) {
html += '<select name="' + name + '" id="' + inputId + '">';
html += '<option value="">-- Bitte wählen --</option>';
for (var i = 0; i < field.options.length; i++) {
html += '<option value="' + esc(field.options[i].value) + '">' + esc(field.options[i].label) + '</option>';
}
html += '</select>';
} else if (type === 'checkbox' && field.options.length > 0) {
html += '<div class="task-cert-dialog-checkbox-group">';
for (var j = 0; j < field.options.length; j++) {
html += '<label class="task-cert-dialog-check-label"><input type="checkbox" name="' + name + '[]" value="' + esc(field.options[j].value) + '"> ' + esc(field.options[j].label) + '</label>';
}
html += '</div>';
} else if (type === 'radio' && field.options.length > 0) {
html += '<div class="task-cert-dialog-radio-group">';
for (var k = 0; k < field.options.length; k++) {
html += '<label class="task-cert-dialog-check-label"><input type="radio" name="' + name + '" value="' + esc(field.options[k].value) + '"> ' + esc(field.options[k].label) + '</label>';
}
html += '</div>';
} else {
var inputType = type === 'number' ? 'number' : (type === 'date' ? 'date' : 'text');
html += '<input type="' + inputType + '" name="' + name + '" id="' + inputId + '" placeholder="' + placeholder + '">';
}
html += '</div>';
return html;
}
function buildDialogContent(data) {
var html = '<div class="task-cert-dialog-inner">';
html += '<div class="task-cert-dialog-header">';
html += '<h3>Als erledigt markieren</h3>';
html += '<button type="button" class="task-cert-dialog-close" aria-label="Schließen">&times;</button>';
html += '</div>';
html += '<div class="task-cert-dialog-info">';
html += '<table class="task-cert-info-table">';
html += '<tr><th>Aufgabe</th><td>' + esc(data.task_title) + '</td></tr>';
html += '<tr><th>Mitarbeiter</th><td>' + esc(data.contact_name);
if (data.contact_email) { html += ' <span class="task-cert-hint">(' + esc(data.contact_email) + ')</span>'; }
html += '</td></tr>';
html += '<tr><th>Zyklus</th><td>' + esc(data.cycle_key) + '</td></tr>';
html += '</table>';
html += '</div>';
html += '<form id="task-cert-mark-completed-form">';
html += '<input type="hidden" name="csrf_token" value="' + esc(data.csrf_token) + '">';
if (data.methods && data.methods.length > 0) {
html += '<div class="task-cert-dialog-methods">';
html += '<h4>Methoden</h4>';
html += '<p class="task-cert-hint">Einreichungen werden automatisch erstellt. Bei Formularen können Sie optional Antworten hinterlegen.</p>';
for (var i = 0; i < data.methods.length; i++) {
html += buildMethodHtml(data.methods[i]);
}
html += '</div>';
}
html += '<div class="task-cert-dialog-note">';
html += '<h4>Admin-Notiz</h4>';
html += '<textarea name="admin_note" rows="2" placeholder="Optionale Begründung (wird im Änderungsprotokoll gespeichert)"></textarea>';
html += '</div>';
html += '<div class="task-cert-dialog-actions">';
html += '<button type="button" class="btn task-cert-dialog-cancel">Abbrechen</button>';
html += '<button type="submit" class="btn task-cert-btn-success">Als erledigt markieren</button>';
html += '</div>';
html += '<div id="task-cert-dialog-error" class="task-cert-error" hidden></div>';
html += '</form>';
html += '</div>';
return html;
}
function collectFormData(data) {
var form = document.getElementById('task-cert-mark-completed-form');
if (!form) { return null; }
var adminNote = (form.querySelector('[name="admin_note"]') || {}).value || '';
var csrfToken = (form.querySelector('[name="csrf_token"]') || {}).value || '';
var methodPayloads = {};
var methods = data.methods || [];
for (var i = 0; i < methods.length; i++) {
var m = methods[i];
if (m.already_fulfilled || m.method_type !== 'form_submit') { continue; }
var fields = m.form_fields || [];
var payload = {};
for (var j = 0; j < fields.length; j++) {
var key = fields[j].field_key;
var name = 'method__' + m.id + '__' + key;
if (fields[j].field_type === 'checkbox') {
var checked = form.querySelectorAll('[name="' + name + '[]"]:checked');
var vals = [];
for (var k = 0; k < checked.length; k++) { vals.push(checked[k].value); }
payload[key] = vals;
} else {
var el = form.querySelector('[name="' + name + '"]');
payload[key] = el ? el.value : '';
}
}
methodPayloads[m.id] = payload;
}
return {
csrf_token: csrfToken,
task_id: taskId,
assignment_id: data.assignment_id,
admin_note: adminNote,
method: methodPayloads
};
}
function showError(msg) {
var errEl = document.getElementById('task-cert-dialog-error');
if (errEl) {
errEl.textContent = msg;
errEl.hidden = false;
}
}
function setLoading(on) {
var btn = dialog.querySelector('[type="submit"]');
if (btn) {
btn.disabled = on;
btn.textContent = on ? 'Wird gespeichert...' : 'Als erledigt markieren';
}
}
var currentData = null;
function openDialog(assignmentId) {
dialog.innerHTML = '<div class="task-cert-dialog-inner"><p>Daten werden geladen...</p></div>';
dialog.showModal();
fetch(methodsUrl + '?assignment_id=' + assignmentId, { credentials: 'same-origin' })
.then(function (res) { return res.json(); })
.then(function (data) {
if (data.error) {
dialog.innerHTML = '<div class="task-cert-dialog-inner"><div class="task-cert-error">' + esc(data.error) + '</div><div class="task-cert-dialog-actions"><button type="button" class="btn task-cert-dialog-cancel">Schließen</button></div></div>';
return;
}
currentData = data;
dialog.innerHTML = buildDialogContent(data);
})
.catch(function (err) {
dialog.innerHTML = '<div class="task-cert-dialog-inner"><div class="task-cert-error">Fehler: ' + esc(err.message) + '</div><div class="task-cert-dialog-actions"><button type="button" class="btn task-cert-dialog-cancel">Schließen</button></div></div>';
});
}
function submitDialog() {
if (!currentData) { return; }
var body = collectFormData(currentData);
if (!body) { return; }
setLoading(true);
fetch(submitUrl, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
.then(function (res) { return res.json(); })
.then(function (result) {
if (result.success) {
dialog.close();
if (window.TaskCertAdminActions && window.TaskCertAdminActions.showToast) {
window.TaskCertAdminActions.showToast('Zuweisung als erledigt markiert.', 'success');
}
setTimeout(function () { location.reload(); }, 800);
} else {
setLoading(false);
showError(result.error || 'Unbekannter Fehler');
}
})
.catch(function (err) {
setLoading(false);
showError('Netzwerkfehler: ' + err.message);
});
}
// Event delegation
document.addEventListener('click', function (e) {
var btn = e.target.closest('.task-cert-btn-mark-completed');
if (btn) {
e.preventDefault();
var aid = parseInt(btn.getAttribute('data-assignment-id') || '0', 10);
if (aid > 0) { openDialog(aid); }
return;
}
if (e.target.closest('.task-cert-dialog-close') || e.target.closest('.task-cert-dialog-cancel')) {
dialog.close();
return;
}
});
dialog.addEventListener('submit', function (e) {
e.preventDefault();
submitDialog();
});
dialog.addEventListener('click', function (e) {
if (e.target === dialog) {
dialog.close();
}
});
})();

View File

@@ -0,0 +1,603 @@
// ── CSV-Export Hilfsfunktionen (global) ──────────────────────────
var TaskCertCsvExport = (function () {
'use strict';
function escapeCsvCell(val) {
var str = String(val == null ? '' : val);
if (str.indexOf('"') !== -1 || str.indexOf(';') !== -1 || str.indexOf('\n') !== -1) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
function buildCsv(headers, rows) {
var lines = [headers.map(escapeCsvCell).join(';')];
for (var i = 0; i < rows.length; i++) {
lines.push(rows[i].map(escapeCsvCell).join(';'));
}
return lines.join('\r\n');
}
function download(csvString, filename) {
var bom = '\uFEFF';
var blob = new Blob([bom + csvString], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
return { escapeCsvCell: escapeCsvCell, buildCsv: buildCsv, download: download };
})();
// ══════════════════════════════════════════════════════════════════
// Übersicht-Grid
// ══════════════════════════════════════════════════════════════════
(function () {
'use strict';
var container = document.getElementById('task-cert-overview-grid');
if (!container) {
return;
}
var dataEl = document.getElementById('task-cert-overview-grid-data');
if (!dataEl) {
return;
}
var allRows = [];
try {
allRows = JSON.parse(dataEl.textContent || '[]');
} catch (e) {
container.textContent = 'Fehler beim Laden der Tabellendaten.';
return;
}
if (!window.gridjs || !window.gridjs.Grid) {
container.textContent = 'Grid.js konnte nicht geladen werden.';
return;
}
var html = gridjs.html;
var statusFilterEl = document.getElementById('task-cert-overview-status-filter');
var searchInputEl = document.getElementById('task-cert-overview-search');
var initialStatusFilter = container.getAttribute('data-initial-status-filter') || '';
// ── Hilfsfunktionen ──────────────────────────────────────────
function escapeHtml(str) {
if (!str) { return ''; }
var div = document.createElement('div');
div.appendChild(document.createTextNode(String(str)));
return div.innerHTML;
}
function stripHtmlTags(val) {
if (!val) { return ''; }
if (typeof val === 'object' && val.props && val.props.content) {
var tmp = document.createElement('div');
tmp.innerHTML = val.props.content;
return (tmp.textContent || tmp.innerText || '').trim();
}
return String(val);
}
function formatDateDisplay(isoValue) {
if (!isoValue) { return '-'; }
try {
var d = new Date(isoValue);
if (isNaN(d.getTime())) { return isoValue; }
var day = ('0' + d.getDate()).slice(-2);
var month = ('0' + (d.getMonth() + 1)).slice(-2);
var year = d.getFullYear();
var hours = ('0' + d.getHours()).slice(-2);
var minutes = ('0' + d.getMinutes()).slice(-2);
return day + '.' + month + '.' + year + ' ' + hours + ':' + minutes;
} catch (e) {
return isoValue;
}
}
// ── Sort-Comparators ─────────────────────────────────────────
function compareHtmlText(a, b) {
var ta = stripHtmlTags(a);
var tb = stripHtmlTags(b);
return ta.localeCompare(tb, 'de');
}
function compareDateValues(a, b) {
var sa = (a && a._sort) || '';
var sb = (b && b._sort) || '';
if (sa === sb) { return 0; }
if (sa === '') { return 1; }
if (sb === '') { return -1; }
return sa < sb ? -1 : 1;
}
function compareNumeric(a, b) {
var ta = stripHtmlTags(a);
var tb = stripHtmlTags(b);
var na = ta === '-' || ta === '' ? -1 : parseFloat(ta) || 0;
var nb = tb === '-' || tb === '' ? -1 : parseFloat(tb) || 0;
return na - nb;
}
function compareStrings(a, b) {
var sa = String(a || '');
var sb = String(b || '');
if (sa === '-') { sa = ''; }
if (sb === '-') { sb = ''; }
return sa.localeCompare(sb, 'de');
}
// ── Zell-Helfer ──────────────────────────────────────────────
function dateCell(isoValue) {
return {
_sort: isoValue || '',
_display: formatDateDisplay(isoValue)
};
}
function dateCellFormatter(cell) {
if (cell && typeof cell === 'object' && cell._display !== undefined) {
return cell._display;
}
return cell || '-';
}
// ── Suchtext-Cache ───────────────────────────────────────────
function buildSearchText(row) {
return [
row.contact_name || '',
row.contact_email || '',
row.status_label || '',
row.cycle_key || '',
row.certificate_label || '',
row.certificate_code || '',
formatDateDisplay(row.assigned_at_sort),
formatDateDisplay(row.due_at_sort),
formatDateDisplay(row.completed_at_sort),
formatDateDisplay(row.overdue_at_sort),
formatDateDisplay(row.last_activity_sort),
formatDateDisplay(row.valid_until_sort)
].join(' ').toLowerCase();
}
allRows.forEach(function (row) {
row._searchText = buildSearchText(row);
});
// ── Grid-Daten bauen ─────────────────────────────────────────
function buildGridData(rows) {
return rows.map(function (row) {
var nameHtml = '<span>' + escapeHtml(row.contact_name || '-') + '</span>';
if (row.contact_email) {
nameHtml += '<br><small>' + escapeHtml(row.contact_email) + '</small>';
}
var statusText = row.status_label || '-';
var escalationHtml = '-';
if (row.grace_badge_html) {
escalationHtml = row.grace_badge_html;
} else if (row.escalation_level !== null && row.escalation_level !== undefined && row.escalation_level !== '') {
escalationHtml = String(row.escalation_level);
}
var certHtml = '-';
if (row.certificate_label) {
certHtml = escapeHtml(row.certificate_label);
if (row.certificate_code) {
certHtml += '<br><small>' + escapeHtml(row.certificate_code) + '</small>';
}
}
var actionHtml = row._row_html_action || '-';
return [
html(nameHtml),
statusText,
dateCell(row.assigned_at_sort),
dateCell(row.due_at_sort),
dateCell(row.completed_at_sort),
dateCell(row.overdue_at_sort),
html(escalationHtml),
dateCell(row.last_activity_sort),
html(certHtml),
dateCell(row.valid_until_sort),
row.cycle_key || '-',
html(actionHtml)
];
});
}
// ── Filter-Logik ─────────────────────────────────────────────
var currentStatusFilter = initialStatusFilter;
var currentSearchTerm = '';
function getFilteredRows() {
var rows = allRows;
if (currentStatusFilter) {
rows = rows.filter(function (row) {
return row.status === currentStatusFilter;
});
}
if (currentSearchTerm) {
var term = currentSearchTerm.toLowerCase();
rows = rows.filter(function (row) {
return row._searchText.indexOf(term) !== -1;
});
}
return rows;
}
function applyFilters() {
grid.updateConfig({
data: buildGridData(getFilteredRows())
}).forceRender();
}
// ── Grid initialisieren ──────────────────────────────────────
var grid = new gridjs.Grid({
columns: [
{ name: 'Mitarbeiter', width: '180px', sort: { compare: compareHtmlText } },
{ name: 'Status', width: '130px', sort: { compare: compareStrings } },
{ name: 'Zugewiesen', width: '130px', formatter: dateCellFormatter, sort: { compare: compareDateValues } },
{ name: 'Fällig', width: '130px', formatter: dateCellFormatter, sort: { compare: compareDateValues } },
{ name: 'Abgeschlossen', width: '130px', formatter: dateCellFormatter, sort: { compare: compareDateValues } },
{ name: 'Überfällig seit', width: '130px', formatter: dateCellFormatter, sort: { compare: compareDateValues } },
{ name: 'Eskalation', width: '90px', sort: { compare: compareNumeric } },
{ name: 'Letzte Akt.', width: '130px', formatter: dateCellFormatter, sort: { compare: compareDateValues } },
{ name: 'Zertifikat', width: '120px', sort: { compare: compareHtmlText } },
{ name: 'Gültig bis', width: '130px', formatter: dateCellFormatter, sort: { compare: compareDateValues } },
{ name: 'Zyklus', width: '90px', sort: { compare: compareStrings } },
{ name: 'Aktion', width: '130px', sort: false }
],
data: buildGridData(getFilteredRows()),
search: false,
sort: {
multiColumn: false
},
pagination: {
enabled: true,
limit: 25
},
language: {
pagination: {
previous: 'Zurück',
next: 'Weiter',
showing: 'Zeige',
of: 'von',
to: 'bis',
results: 'Einträge'
},
noRecordsFound: 'Keine Einträge gefunden.',
loading: 'Laden...',
error: 'Fehler beim Laden der Daten.'
},
className: {
table: 'task-cert-gridjs-table',
th: 'task-cert-gridjs-th',
td: 'task-cert-gridjs-td'
},
autoWidth: false
});
grid.render(container);
// ── Event-Handler ────────────────────────────────────────────
if (statusFilterEl) {
if (initialStatusFilter) {
statusFilterEl.value = initialStatusFilter;
}
statusFilterEl.addEventListener('change', function () {
currentStatusFilter = this.value;
applyFilters();
});
}
if (searchInputEl) {
var searchTimeout = null;
searchInputEl.addEventListener('input', function () {
var val = this.value.trim();
if (searchTimeout) { clearTimeout(searchTimeout); }
searchTimeout = setTimeout(function () {
currentSearchTerm = val;
applyFilters();
}, 200);
});
}
// CSV-Export Übersicht
var exportBtn = document.getElementById('task-cert-overview-export');
if (exportBtn) {
exportBtn.addEventListener('click', function () {
var rows = getFilteredRows();
var headers = ['Mitarbeiter', 'E-Mail', 'Status', 'Zugewiesen', 'Fällig', 'Abgeschlossen',
'Überfällig seit', 'Eskalation', 'Letzte Aktivität', 'Zertifikat', 'Gültig bis', 'Zyklus'];
var csvRows = rows.map(function (row) {
return [
row.contact_name || '',
row.contact_email || '',
row.status_label || '',
formatDateDisplay(row.assigned_at_sort),
formatDateDisplay(row.due_at_sort),
formatDateDisplay(row.completed_at_sort),
formatDateDisplay(row.overdue_at_sort),
row.escalation_level != null ? row.escalation_level : '',
formatDateDisplay(row.last_activity_sort),
row.certificate_label || '',
formatDateDisplay(row.valid_until_sort),
row.cycle_key || ''
];
});
var csv = TaskCertCsvExport.buildCsv(headers, csvRows);
TaskCertCsvExport.download(csv, 'uebersicht-export.csv');
});
}
})();
// ══════════════════════════════════════════════════════════════════
// Antworten-Grid
// ══════════════════════════════════════════════════════════════════
(function () {
'use strict';
var container = document.getElementById('task-cert-answers-grid');
if (!container) { return; }
var dataEl = document.getElementById('task-cert-answers-grid-data');
if (!dataEl) { return; }
var allRows = [];
try {
allRows = JSON.parse(dataEl.textContent || '[]');
} catch (e) {
container.textContent = 'Fehler beim Laden der Tabellendaten.';
return;
}
if (!window.gridjs || !window.gridjs.Grid) {
container.textContent = 'Grid.js konnte nicht geladen werden.';
return;
}
var html = gridjs.html;
var searchInputEl = document.getElementById('task-cert-answers-search');
function escapeHtml(str) {
if (!str) { return ''; }
var div = document.createElement('div');
div.appendChild(document.createTextNode(String(str)));
return div.innerHTML;
}
function formatDateDisplay(isoValue) {
if (!isoValue) { return '-'; }
try {
var d = new Date(isoValue);
if (isNaN(d.getTime())) { return isoValue; }
var day = ('0' + d.getDate()).slice(-2);
var month = ('0' + (d.getMonth() + 1)).slice(-2);
var year = d.getFullYear();
var hours = ('0' + d.getHours()).slice(-2);
var minutes = ('0' + d.getMinutes()).slice(-2);
return day + '.' + month + '.' + year + ' ' + hours + ':' + minutes;
} catch (e) {
return isoValue;
}
}
function compareStrings(a, b) {
var sa = String(a || '');
var sb = String(b || '');
if (sa === '-') { sa = ''; }
if (sb === '-') { sb = ''; }
return sa.localeCompare(sb, 'de');
}
function compareDateValues(a, b) {
var sa = (a && a._sort) || '';
var sb = (b && b._sort) || '';
if (sa === sb) { return 0; }
if (sa === '') { return 1; }
if (sb === '') { return -1; }
return sa < sb ? -1 : 1;
}
function compareHtmlText(a, b) {
var ta = stripHtmlTags(a);
var tb = stripHtmlTags(b);
return ta.localeCompare(tb, 'de');
}
function compareNumeric(a, b) {
var ta = stripHtmlTags(a);
var tb = stripHtmlTags(b);
var na = ta === '-' || ta === '' ? -1 : parseFloat(ta) || 0;
var nb = tb === '-' || tb === '' ? -1 : parseFloat(tb) || 0;
return na - nb;
}
function stripHtmlTags(val) {
if (!val) { return ''; }
if (typeof val === 'object' && val.props && val.props.content) {
var tmp = document.createElement('div');
tmp.innerHTML = val.props.content;
return (tmp.textContent || tmp.innerText || '').trim();
}
return String(val);
}
function dateCell(isoValue) {
return { _sort: isoValue || '', _display: formatDateDisplay(isoValue) };
}
function dateCellFormatter(cell) {
if (cell && typeof cell === 'object' && cell._display !== undefined) {
return cell._display;
}
return cell || '-';
}
// Suchtext-Cache
allRows.forEach(function (row) {
row._searchText = [
row.contact_name || '',
row.contact_email || '',
row.method_label || '',
row.status_label || '',
row.score || '',
formatDateDisplay(row.submitted_at_sort)
].join(' ').toLowerCase();
});
function buildGridData(rows) {
return rows.map(function (row) {
var nameHtml = '<span>' + escapeHtml(row.contact_name || '-') + '</span>';
if (row.contact_email) {
nameHtml += '<br><small>' + escapeHtml(row.contact_email) + '</small>';
}
var detailHtml = row.detail_html || '-';
if (detailHtml !== '-') {
detailHtml = '<details><summary>Anzeigen</summary>' + detailHtml + '</details>';
}
return [
html(nameHtml),
row.method_label || '-',
row.status_label || '-',
dateCell(row.submitted_at_sort),
row.score || '-',
html(detailHtml)
];
});
}
var currentSearchTerm = '';
function getFilteredRows() {
var rows = allRows;
if (currentSearchTerm) {
var term = currentSearchTerm.toLowerCase();
rows = rows.filter(function (row) {
return row._searchText.indexOf(term) !== -1;
});
}
return rows;
}
function applyFilters() {
answersGrid.updateConfig({
data: buildGridData(getFilteredRows())
}).forceRender();
}
var answersGrid = new gridjs.Grid({
columns: [
{ name: 'Mitarbeiter', width: '180px', sort: { compare: compareHtmlText } },
{ name: 'Methode', width: '130px', sort: { compare: compareStrings } },
{ name: 'Status', width: '130px', sort: { compare: compareStrings } },
{ name: 'Eingereicht', width: '140px', formatter: dateCellFormatter, sort: { compare: compareDateValues } },
{ name: 'Punkte', width: '80px', sort: { compare: compareStrings } },
{ name: 'Details', width: '250px', sort: false }
],
data: buildGridData(getFilteredRows()),
search: false,
sort: { multiColumn: false },
pagination: { enabled: true, limit: 25 },
language: {
pagination: {
previous: 'Zurück',
next: 'Weiter',
showing: 'Zeige',
of: 'von',
to: 'bis',
results: 'Einträge'
},
noRecordsFound: 'Keine Einträge gefunden.',
loading: 'Laden...',
error: 'Fehler beim Laden der Daten.'
},
className: {
table: 'task-cert-gridjs-table',
th: 'task-cert-gridjs-th',
td: 'task-cert-gridjs-td'
},
autoWidth: false
});
answersGrid.render(container);
if (searchInputEl) {
var searchTimeout = null;
searchInputEl.addEventListener('input', function () {
var val = this.value.trim();
if (searchTimeout) { clearTimeout(searchTimeout); }
searchTimeout = setTimeout(function () {
currentSearchTerm = val;
applyFilters();
}, 200);
});
}
// CSV-Export Antworten (mit Antwort-Details als einzelne Spalten)
var exportBtn = document.getElementById('task-cert-answers-export');
if (exportBtn) {
exportBtn.addEventListener('click', function () {
var rows = getFilteredRows();
// Alle einzigartigen Antwort-Labels sammeln (Reihenfolge beibehalten)
var fieldLabels = [];
var fieldLabelSet = {};
for (var i = 0; i < rows.length; i++) {
var fields = rows[i].answer_fields || {};
for (var label in fields) {
if (fields.hasOwnProperty(label) && !fieldLabelSet[label]) {
fieldLabelSet[label] = true;
fieldLabels.push(label);
}
}
}
var headers = ['Mitarbeiter', 'E-Mail', 'Methode', 'Status', 'Eingereicht', 'Punkte'];
headers = headers.concat(fieldLabels);
var csvRows = rows.map(function (row) {
var base = [
row.contact_name || '',
row.contact_email || '',
row.method_label || '',
row.status_label || '',
formatDateDisplay(row.submitted_at_sort),
row.score || ''
];
var fields = row.answer_fields || {};
for (var j = 0; j < fieldLabels.length; j++) {
base.push(fields[fieldLabels[j]] || '');
}
return base;
});
var csv = TaskCertCsvExport.buildCsv(headers, csvRows);
TaskCertCsvExport.download(csv, 'antworten-export.csv');
});
}
})();

View File

@@ -0,0 +1,122 @@
(function (window, document) {
'use strict';
function toArray(nodeList) {
return Array.prototype.slice.call(nodeList || []);
}
function dayTokenLocal() {
var now = new Date();
var year = String(now.getFullYear());
var month = String(now.getMonth() + 1).padStart(2, '0');
var day = String(now.getDate()).padStart(2, '0');
return year + '-' + month + '-' + day;
}
function storageKey(viewerId, dayToken) {
return 'taskCertEscOverlayAck:v1:' + viewerId + ':' + dayToken;
}
function hasAck(key) {
try {
return window.localStorage.getItem(key) === '1';
} catch (error) {
return false;
}
}
function setAck(key) {
try {
window.localStorage.setItem(key, '1');
return true;
} catch (error) {
return false;
}
}
function closeAndCleanup(root, overlay) {
if (overlay) {
overlay.hidden = true;
}
document.body.classList.remove('task-cert-user-escalation-overlay-open');
if (root && root.parentNode) {
root.parentNode.removeChild(root);
}
}
function initEscalationOverlay(root) {
if (!(root instanceof window.HTMLElement)) {
return;
}
if (root.dataset.overlayInit === '1') {
return;
}
root.dataset.overlayInit = '1';
var overlay = root.querySelector('.task-cert-user-escalation-overlay');
if (!(overlay instanceof window.HTMLElement)) {
closeAndCleanup(root, null);
return;
}
var viewerId = parseInt(root.getAttribute('data-viewer-id') || '0', 10);
if (!viewerId || viewerId <= 0) {
closeAndCleanup(root, overlay);
return;
}
var key = storageKey(viewerId, dayTokenLocal());
if (hasAck(key)) {
closeAndCleanup(root, overlay);
return;
}
var myTasksUrl = root.getAttribute('data-my-tasks-url') || '';
var myTasksLink = root.querySelector('[data-overlay-my-tasks]');
var ackButton = root.querySelector('[data-overlay-ack]');
if (myTasksLink instanceof window.HTMLAnchorElement && myTasksUrl !== '') {
myTasksLink.href = myTasksUrl;
}
overlay.hidden = false;
document.body.classList.add('task-cert-user-escalation-overlay-open');
if (ackButton instanceof window.HTMLButtonElement) {
ackButton.focus();
ackButton.addEventListener('click', function () {
setAck(key);
closeAndCleanup(root, overlay);
});
}
if (myTasksLink instanceof window.HTMLAnchorElement) {
myTasksLink.addEventListener('click', function () {
setAck(key);
});
}
}
function autoInit() {
var roots = toArray(document.querySelectorAll('[data-task-cert-escalation-overlay]'));
if (roots.length === 0) {
return;
}
var primary = roots[0];
for (var i = 1; i < roots.length; i += 1) {
if (roots[i] && roots[i].parentNode) {
roots[i].parentNode.removeChild(roots[i]);
}
}
initEscalationOverlay(primary);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', autoInit);
} else {
autoInit();
}
})(window, document);

View File

@@ -0,0 +1,78 @@
(function () {
'use strict';
var textarea = document.getElementById('description');
if (!textarea) return;
var form = textarea.closest('form');
if (!form) return;
// Create editor container
var editorContainer = document.createElement('div');
editorContainer.id = 'description-editor';
textarea.parentNode.insertBefore(editorContainer, textarea.nextSibling);
// Hide original textarea
textarea.style.display = 'none';
// Determine initial content
var initialContent = textarea.value || '';
var isHtml = /<[a-z][\s\S]*>/i.test(initialContent);
// Better-Table-Modul registrieren (idempotent)
if (typeof quillBetterTable !== 'undefined') {
Quill.register({ 'modules/better-table': quillBetterTable }, true);
}
// Initialize Quill auf Goldstandard-Compact (kein Image/Video)
var quill = new Quill('#description-editor', {
theme: 'snow',
placeholder: 'Beschreibung eingeben...',
modules: {
toolbar: {
container: window.QuillToolbarPresets.compact(),
handlers: {
table: function () {
var tableModule = this.quill.getModule('better-table');
if (tableModule) tableModule.insertTable(3, 3);
}
}
},
table: false,
'better-table': window.QuillToolbarPresets.betterTableConfig(),
keyboard: (typeof quillBetterTable !== 'undefined')
? { bindings: quillBetterTable.keyboardBindings }
: undefined,
imageResize: true
}
});
// Load existing content via dangerouslyPasteHTML statt innerHTML —
// so behält Quill 2.x auch alte Listen-Strukturen korrekt.
if (initialContent !== '') {
if (isHtml) {
quill.clipboard.dangerouslyPasteHTML(0, initialContent, 'silent');
} else {
// Plain text: convert newlines to paragraphs
var paragraphs = initialContent.split(/\n\n+/);
var html = paragraphs.map(function (p) {
var escaped = p.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
escaped = escaped.replace(/\n/g, '<br>');
return '<p>' + escaped + '</p>';
}).join('');
quill.clipboard.dangerouslyPasteHTML(0, html, 'silent');
}
}
// Sync Quill content to textarea before form submit
form.addEventListener('submit', function () {
var html = quill.root.innerHTML || '';
// Treat Quill empty state as empty string
if (html === '<p><br></p>' || html.replace(/<p>\s*<br\s*\/?>\s*<\/p>/g, '').trim() === '') {
textarea.value = '';
} else {
textarea.value = html;
}
});
})();

View File

@@ -0,0 +1,139 @@
(function (window, document) {
'use strict';
function toArray(nodeList) {
return Array.prototype.slice.call(nodeList || []);
}
function initTaskCertTabs(root) {
if (!(root instanceof window.HTMLElement)) {
return;
}
if (root.dataset.tabsInit === '1') {
return;
}
var tabs = toArray(root.querySelectorAll('[data-tabs-tab]'));
var panels = toArray(root.querySelectorAll('[data-tabs-panel]'));
if (tabs.length === 0 || panels.length === 0) {
return;
}
root.dataset.tabsInit = '1';
function activate(targetKey) {
var key = String(targetKey);
var activeTabKey = key;
tabs.forEach(function (tab) {
var isActive = tab.getAttribute('data-tabs-tab') === key;
tab.classList.toggle('is-active', isActive);
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
tab.setAttribute('tabindex', isActive ? '0' : '-1');
if (isActive) {
activeTabKey = tab.getAttribute('data-tab-key') || key;
}
});
panels.forEach(function (panel) {
var isActive = panel.getAttribute('data-tabs-panel') === key;
panel.classList.toggle('is-active', isActive);
panel.hidden = !isActive;
});
toArray(root.querySelectorAll('[data-tabs-active-input]')).forEach(function (input) {
if (input instanceof window.HTMLInputElement) {
input.value = activeTabKey;
}
});
}
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
activate(tab.getAttribute('data-tabs-tab'));
});
});
var initial = root.getAttribute('data-tabs-active');
var initialTab = null;
if (initial !== null) {
for (var i = 0; i < tabs.length; i += 1) {
if (tabs[i].getAttribute('data-tabs-tab') === initial) {
initialTab = tabs[i];
break;
}
}
}
if (initialTab) {
activate(initialTab.getAttribute('data-tabs-tab'));
} else {
activate(tabs[0].getAttribute('data-tabs-tab'));
}
toArray(root.querySelectorAll('form[data-cycle-switch-form]')).forEach(function (form) {
if (!(form instanceof window.HTMLFormElement)) {
return;
}
if (form.dataset.cycleSwitchInit === '1') {
return;
}
form.dataset.cycleSwitchInit = '1';
form.addEventListener('submit', function (event) {
var method = String(form.getAttribute('method') || 'get').toUpperCase();
if (method !== 'GET') {
return;
}
event.preventDefault();
var action = form.getAttribute('action') || window.location.href;
var actionUrl;
try {
actionUrl = new window.URL(action, window.location.origin);
} catch (err) {
form.submit();
return;
}
var params = new window.URLSearchParams(actionUrl.search || '');
var formData = new window.FormData(form);
formData.forEach(function (value, key) {
if (typeof value !== 'string') {
return;
}
params.set(key, value);
});
params.delete('load_guard');
var query = params.toString();
var redirectUrl = actionUrl.pathname;
if (query !== '') {
redirectUrl += '?' + query + '&load_guard=/';
} else {
redirectUrl += '?load_guard=/';
}
if (actionUrl.hash) {
redirectUrl += actionUrl.hash;
}
window.location.assign(redirectUrl);
});
});
}
function autoInit() {
toArray(document.querySelectorAll('[data-task-cert-tabs]')).forEach(initTaskCertTabs);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', autoInit);
} else {
autoInit();
}
})(window, document);

700
module/tasks/bootstrap.php Normal file
View File

@@ -0,0 +1,700 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/lib/Logger.php';
if (!function_exists('task_cert_bootstrap_output_warning')) {
function task_cert_bootstrap_output_warning(string $message): void
{
if (PHP_SAPI === 'cli') {
fwrite(STDERR, $message . PHP_EOL);
return;
}
$safeMessage = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
$accept = (string) ($_SERVER['HTTP_ACCEPT'] ?? '');
$isAjax = strtolower((string) ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
$expectsJson = (
strpos($requestUri, '/endpoints/') !== false
|| strpos($accept, 'application/json') !== false
|| $isAjax
);
if ($expectsJson) {
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
}
echo json_encode([
'success' => false,
'error' => $message,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
}
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
}
echo '<div class="task-cert"><div class="task-cert-error">' . $safeMessage . '</div></div>';
}
}
if (!function_exists('task_cert_bootstrap_truthy')) {
function task_cert_bootstrap_truthy(string $value): bool
{
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on', 'always'], true);
}
}
if (!function_exists('task_cert_is_endpoint_request')) {
function task_cert_is_endpoint_request(): bool
{
$uri = strtolower((string) ($_SERVER['REQUEST_URI'] ?? ''));
$scriptName = strtolower((string) ($_SERVER['SCRIPT_NAME'] ?? ''));
$scriptFilename = strtolower((string) ($_SERVER['SCRIPT_FILENAME'] ?? ''));
return (
strpos($uri, '/endpoints/') !== false
|| strpos($scriptName, '/endpoints/') !== false
|| strpos($scriptFilename, '/endpoints/') !== false
);
}
}
if (!function_exists('task_cert_has_monolith_context')) {
function task_cert_has_monolith_context(): bool
{
return (
is_array($GLOBALS['site'] ?? null)
&& is_array($GLOBALS['language'] ?? null)
&& (
is_array($GLOBALS['main_contact'] ?? null)
|| isset($_SESSION['login_id'])
)
);
}
}
if (!function_exists('task_cert_bootstrap_context_fallback')) {
function task_cert_bootstrap_context_fallback(): void
{
if (!is_array($GLOBALS['site'] ?? null)) {
$GLOBALS['site'] = [];
}
if (!is_array($GLOBALS['language'] ?? null)) {
$GLOBALS['language'] = [];
}
if (!is_array($GLOBALS['main_contact'] ?? null)) {
$GLOBALS['main_contact'] = [];
}
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '');
$path = parse_url($uri, PHP_URL_PATH);
$segments = array_values(array_filter(explode('/', trim((string) $path, '/')), static function (string $segment): bool {
return $segment !== '';
}));
$siteCode = '';
$languageCode = '';
if (!task_cert_is_endpoint_request()) {
if (isset($segments[0])) {
$siteCode = strtolower((string) (preg_replace('/[^a-z0-9_-]/i', '', $segments[0]) ?? ''));
}
if (isset($segments[1])) {
$languageCode = strtolower((string) (preg_replace('/[^a-z0-9_-]/i', '', $segments[1]) ?? ''));
}
}
if (($GLOBALS['site']['code'] ?? '') === '') {
$GLOBALS['site']['code'] = $siteCode !== '' ? $siteCode : 'intranet';
}
if (($GLOBALS['language']['code'] ?? '') === '') {
$GLOBALS['language']['code'] = $languageCode !== '' ? $languageCode : 'de';
}
if (!isset($GLOBALS['site']['id'])) {
$GLOBALS['site']['id'] = 0;
}
if (!isset($GLOBALS['language']['id'])) {
$GLOBALS['language']['id'] = 1;
}
$loginId = (int) ($_SESSION['login_id'] ?? ($_SESSION['main_contact_id'] ?? ($_SESSION['contact_id'] ?? 0)));
if ((int) ($GLOBALS['main_contact']['id'] ?? 0) <= 0 && isset($_SESSION['main_contact']) && is_array($_SESSION['main_contact'])) {
$GLOBALS['main_contact'] = $_SESSION['main_contact'];
}
$conn = $GLOBALS['mysql_con'] ?? null;
if ($loginId > 0 && (int) ($GLOBALS['main_contact']['id'] ?? 0) <= 0 && $conn instanceof mysqli) {
try {
$stmt = $conn->prepare('SELECT * FROM main_contact WHERE id = ? LIMIT 1');
if ($stmt instanceof mysqli_stmt) {
$stmt->bind_param('i', $loginId);
$stmt->execute();
$result = $stmt->get_result();
if ($result instanceof mysqli_result) {
$row = $result->fetch_assoc();
if (is_array($row)) {
$GLOBALS['main_contact'] = $row;
}
$result->free();
}
$stmt->close();
}
} catch (Throwable $throwable) {
TaskCertLogger::warning('Fallback context: could not load main_contact', [
'login_id' => $loginId,
'exception' => $throwable,
]);
}
}
if ((int) ($GLOBALS['main_contact']['id'] ?? 0) > 0 && !isset($_SESSION['main_contact'])) {
$_SESSION['main_contact'] = $GLOBALS['main_contact'];
}
}
}
if (!defined('TASK_CERT_BOOTSTRAPPED')) {
define('TASK_CERT_BOOTSTRAPPED', true);
$taskCertModuleDir = __DIR__;
$rootCandidates = [
dirname(dirname($taskCertModuleDir)),
dirname($taskCertModuleDir),
$taskCertModuleDir,
];
$rootDir = $rootCandidates[0];
foreach ($rootCandidates as $candidate) {
if (
file_exists($candidate . '/mysyde/frontend/frontend_functions.inc.php') ||
file_exists($candidate . '/vendor/autoload.php') ||
is_dir($candidate . '/config')
) {
$rootDir = $candidate;
break;
}
}
if (!defined('TASK_CERT_USERDATA_DIR')) {
define('TASK_CERT_USERDATA_DIR', $rootDir . '/userdata');
}
$autoloadFile = $rootDir . '/vendor/autoload.php';
if (file_exists($autoloadFile)) {
require_once $autoloadFile;
}
$frontendFunctions = $rootDir . '/mysyde/frontend/frontend_functions.inc.php';
if (file_exists($frontendFunctions)) {
require_once $frontendFunctions;
}
$commonFunctions = $rootDir . '/mysyde/common/common_functions.inc.php';
if (file_exists($commonFunctions)) {
require_once $commonFunctions;
}
$intranetFunctionCandidates = [
$rootDir . '/mysyde/intranet/intranet_functions.inc.php',
$rootDir . '/mysyde/frontend/intranet_functions.inc.php',
$rootDir . '/intranet_functions.inc.php',
];
foreach ($intranetFunctionCandidates as $intranetFunctions) {
if (file_exists($intranetFunctions)) {
require_once $intranetFunctions;
break;
}
}
$envDir = $rootDir . '/config';
$envFile = $envDir . '/.env';
if (is_dir($envDir) && file_exists($envFile) && class_exists('\Dotenv\Dotenv')) {
$dotenv = new \Dotenv\Dotenv($envDir);
$dotenv->load();
}
$connEnv = null;
if (function_exists('local_environment')) {
$connEnv = local_environment()
? $rootDir . '/mysyde/dc.config.php'
: $rootDir . '/mysyde/dc-server.config.php';
}
if ($connEnv !== null && file_exists($connEnv)) {
require_once $connEnv;
}
$startIncPath = $rootDir . '/mysyde/frontend/start.inc.php';
$startIncMode = strtolower(trim((string) (getenv('TASK_CERT_BOOTSTRAP_START_INC') ?: ($_ENV['TASK_CERT_BOOTSTRAP_START_INC'] ?? 'auto'))));
$shouldLoadStartInc = false;
if ($startIncMode === 'always' || task_cert_bootstrap_truthy($startIncMode)) {
$shouldLoadStartInc = true;
} elseif (in_array($startIncMode, ['never', '0', 'false', 'off'], true)) {
$shouldLoadStartInc = false;
} else {
// Auto mode: include start.inc for view-like requests, skip for direct endpoint calls.
$shouldLoadStartInc = !task_cert_is_endpoint_request();
}
if (!task_cert_has_monolith_context() && $shouldLoadStartInc && file_exists($startIncPath)) {
try {
require_once $startIncPath;
TaskCertLogger::info('Monolith start.inc included', [
'path' => $startIncPath,
'mode' => $startIncMode,
'request_uri' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
]);
} catch (Throwable $throwable) {
TaskCertLogger::warning('Monolith start.inc include failed, continuing with fallback', [
'path' => $startIncPath,
'mode' => $startIncMode,
'exception' => $throwable,
]);
}
}
if (session_status() === PHP_SESSION_NONE) {
// Endpoint-Requests überspringen start.inc.php (kein session_name-Setup durch den Monolith).
// Cookie-Detection stellt sicher, dass wir dieselbe Session wie der Monolith verwenden
// (Monolith setzt den Session-Namen auf "sid{site_code}", z.B. "sidawo").
if (session_name() === 'PHPSESSID') {
foreach (array_keys($_COOKIE) as $_tc_sn) {
if (strncmp($_tc_sn, 'sid', 3) === 0) {
session_name($_tc_sn);
break;
}
}
unset($_tc_sn);
}
session_start();
}
$dbConnectError = '';
if (!isset($GLOBALS['mysql_con']) || !($GLOBALS['mysql_con'] instanceof mysqli)) {
if (function_exists('db_connect')) {
try {
db_connect();
} catch (Throwable $throwable) {
$dbConnectError = $throwable->getMessage();
}
}
}
if (!isset($GLOBALS['mysql_con']) || !($GLOBALS['mysql_con'] instanceof mysqli)) {
mysqli_report(MYSQLI_REPORT_OFF);
$mysqlCon = @mysqli_connect(
getenv('MAIN_MYSQL_DB_HOST') ?: '127.0.0.1',
getenv('MAIN_MYSQL_DB_USER') ?: '',
getenv('MAIN_MYSQL_DB_PASS') ?: '',
getenv('MAIN_MYSQL_DB_SCHEMA') ?: '',
(int) (getenv('MAIN_MYSQL_DB_PORT') ?: 3306)
);
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
if ($mysqlCon) {
mysqli_set_charset($mysqlCon, 'utf8mb4');
$GLOBALS['mysql_con'] = $mysqlCon;
} else {
$driverError = mysqli_connect_error();
$errorParts = [];
if ($dbConnectError !== '') {
$errorParts[] = 'Legacy-Connector: ' . $dbConnectError;
}
if ($driverError !== '') {
$errorParts[] = 'mysqli: ' . $driverError;
}
$details = $errorParts !== [] ? ' Details: ' . implode(' | ', $errorParts) : '';
$message = 'task_cert: Datenbankverbindung konnte nicht aufgebaut werden.' . $details;
$GLOBALS['task_cert_bootstrap_warning'] = $message;
TaskCertLogger::error('Bootstrap DB connection failed', [
'message' => $message,
'db_connect_error' => $dbConnectError,
'mysqli_error' => $driverError,
]);
task_cert_bootstrap_output_warning($message);
exit(PHP_SAPI === 'cli' ? 1 : 0);
}
}
if (!task_cert_has_monolith_context()) {
task_cert_bootstrap_context_fallback();
TaskCertLogger::info('Monolith context fallback applied', [
'site_code' => (string) ($GLOBALS['site']['code'] ?? ''),
'language_code' => (string) ($GLOBALS['language']['code'] ?? ''),
'contact_id' => (int) ($GLOBALS['main_contact']['id'] ?? 0),
'request_uri' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
]);
}
require_once __DIR__ . '/lib/Db.php';
require_once __DIR__ . '/lib/AuthContext.php';
require_once __DIR__ . '/lib/Request.php';
require_once __DIR__ . '/lib/Response.php';
require_once __DIR__ . '/lib/View.php';
require_once __DIR__ . '/lib/Csrf.php';
require_once __DIR__ . '/lib/ContactPrefillColumns.php';
require_once __DIR__ . '/lib/CronHeartbeat.php';
require_once __DIR__ . '/lib/TaskCertConstants.php';
require_once __DIR__ . '/lib/TaskUploadPolicy.php';
require_once __DIR__ . '/lib/TaskUploadStorage.php';
require_once __DIR__ . '/lib/Html.php';
require_once __DIR__ . '/repo/TaskDefinitionRepository.php';
require_once __DIR__ . '/repo/TaskChangeLogRepository.php';
require_once __DIR__ . '/repo/TaskCategoryRepository.php';
require_once __DIR__ . '/repo/TaskCategoryChangeLogRepository.php';
require_once __DIR__ . '/repo/TaskLookupRepository.php';
require_once __DIR__ . '/repo/TaskMonitoringRepository.php';
require_once __DIR__ . '/repo/TaskRuleRepository.php';
require_once __DIR__ . '/repo/TaskAssignmentRepository.php';
require_once __DIR__ . '/repo/TaskSubmissionRepository.php';
require_once __DIR__ . '/repo/TaskCertificateRepository.php';
require_once __DIR__ . '/service/TaskDefinitionService.php';
require_once __DIR__ . '/service/TaskCategoryService.php';
require_once __DIR__ . '/service/TaskLookupService.php';
require_once __DIR__ . '/service/TaskMonitoringService.php';
require_once __DIR__ . '/service/CycleCalculatorService.php';
require_once __DIR__ . '/service/AssignmentResolverService.php';
require_once __DIR__ . '/service/CompletionService.php';
require_once __DIR__ . '/service/ApprovalService.php';
require_once __DIR__ . '/service/CertificateService.php';
require_once __DIR__ . '/service/EscalationService.php';
}
function task_cert_services(): array
{
static $services = null;
if ($services !== null) {
return $services;
}
$definitionRepo = new TaskDefinitionRepository();
$changeLogRepo = new TaskChangeLogRepository();
$taskCategoryRepo = new TaskCategoryRepository();
$taskCategoryChangeLogRepo = new TaskCategoryChangeLogRepository();
$lookupRepo = new TaskLookupRepository();
$monitoringRepo = new TaskMonitoringRepository();
$ruleRepo = new TaskRuleRepository();
$assignmentRepo = new TaskAssignmentRepository();
$submissionRepo = new TaskSubmissionRepository();
$certificateRepo = new TaskCertificateRepository();
$cycleCalculatorService = new CycleCalculatorService();
$certificateService = new CertificateService(
$certificateRepo,
$assignmentRepo,
$definitionRepo,
$cycleCalculatorService
);
$assignmentResolverService = new AssignmentResolverService(
$definitionRepo,
$ruleRepo,
$assignmentRepo,
$submissionRepo,
$certificateRepo,
$cycleCalculatorService,
$changeLogRepo
);
$taskDefinitionService = new TaskDefinitionService(
$definitionRepo,
$taskCategoryRepo,
$ruleRepo,
$assignmentRepo,
$submissionRepo,
$certificateRepo,
$cycleCalculatorService,
$assignmentResolverService,
$changeLogRepo
);
$completionService = new CompletionService(
$ruleRepo,
$assignmentRepo,
$submissionRepo,
$certificateService,
$definitionRepo,
$assignmentResolverService
);
$services = [
'definitionRepo' => $definitionRepo,
'taskCategoryRepo' => $taskCategoryRepo,
'taskCategoryChangeLogRepo' => $taskCategoryChangeLogRepo,
'lookupRepo' => $lookupRepo,
'changeLogRepo' => $changeLogRepo,
'monitoringRepo' => $monitoringRepo,
'ruleRepo' => $ruleRepo,
'assignmentRepo' => $assignmentRepo,
'submissionRepo' => $submissionRepo,
'certificateRepo' => $certificateRepo,
'cycleCalculatorService' => $cycleCalculatorService,
'taskCategoryService' => new TaskCategoryService(
$taskCategoryRepo,
$taskCategoryChangeLogRepo
),
'lookupService' => new TaskLookupService($lookupRepo),
'taskMonitoringService' => new TaskMonitoringService(
$monitoringRepo,
$cycleCalculatorService,
$ruleRepo
),
'taskDefinitionService' => $taskDefinitionService,
'assignmentResolverService' => $assignmentResolverService,
'completionService' => $completionService,
'approvalService' => new ApprovalService(
$submissionRepo,
$assignmentRepo,
$completionService
),
'certificateService' => $certificateService,
'escalationService' => new EscalationService(
$assignmentRepo,
$ruleRepo,
$changeLogRepo
),
];
return $services;
}
function task_cert_asset_url(string $relativeAssetPath): string
{
$cleanPath = ltrim($relativeAssetPath, '/');
return task_cert_url('assets/' . $cleanPath);
}
function task_cert_base_url(): string
{
static $baseUrl = null;
if ($baseUrl !== null) {
return $baseUrl;
}
$configured = (string) (getenv('TASK_CERT_BASE_URL') ?: ($_ENV['TASK_CERT_BASE_URL'] ?? ''));
if (trim($configured) === '') {
$configured = '/module/tasks';
}
$configured = '/' . trim($configured, '/');
$baseUrl = ($configured === '/') ? '' : $configured;
return $baseUrl;
}
function task_cert_site_code(): string
{
$siteCode = '';
$siteGlobal = $GLOBALS['site'] ?? null;
if (is_array($siteGlobal)) {
$siteCode = (string) ($siteGlobal['code'] ?? ($siteGlobal['site_code'] ?? ''));
}
if ($siteCode === '') {
$siteCode = (string) ($GLOBALS['site_code'] ?? '');
}
if ($siteCode === '' && isset($_SESSION['site']) && is_array($_SESSION['site'])) {
$siteCode = (string) ($_SESSION['site']['code'] ?? ($_SESSION['site']['site_code'] ?? ''));
}
if ($siteCode === '') {
$siteCode = (string) ($_SESSION['site_code'] ?? '');
}
if ($siteCode === '') {
if (!task_cert_is_endpoint_request()) {
$requestPath = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
$segments = array_values(array_filter(explode('/', trim($requestPath, '/'))));
if (isset($segments[0])) {
$siteCode = (string) $segments[0];
}
}
}
$siteCode = strtolower(trim($siteCode));
$siteCode = preg_replace('/[^a-z0-9_-]/', '', $siteCode) ?? '';
return $siteCode !== '' ? $siteCode : 'intranet';
}
function task_cert_language_code(): string
{
$languageCode = '';
$languageGlobal = $GLOBALS['language'] ?? null;
if (is_array($languageGlobal)) {
$languageCode = (string) ($languageGlobal['code'] ?? '');
}
if ($languageCode === '' && isset($_SESSION['language']) && is_array($_SESSION['language'])) {
$languageCode = (string) ($_SESSION['language']['code'] ?? '');
}
if ($languageCode === '') {
$languageCode = (string) ($GLOBALS['language_code'] ?? ($_SESSION['language_code'] ?? ''));
}
if ($languageCode === '') {
if (!task_cert_is_endpoint_request()) {
$requestPath = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
$segments = array_values(array_filter(explode('/', trim($requestPath, '/'))));
if (isset($segments[1])) {
$languageCode = (string) $segments[1];
}
}
}
$languageCode = strtolower(trim($languageCode));
$languageCode = preg_replace('/[^a-z0-9_-]/', '', $languageCode) ?? '';
return $languageCode !== '' ? $languageCode : 'de';
}
function task_cert_view_base_url(): string
{
static $baseUrl = null;
if ($baseUrl !== null) {
return $baseUrl;
}
$configured = trim((string) (getenv('TASK_CERT_VIEW_BASE_URL') ?: ($_ENV['TASK_CERT_VIEW_BASE_URL'] ?? '')));
if ($configured !== '') {
$configured = '/' . trim($configured, '/');
$baseUrl = ($configured === '/') ? '' : $configured;
return $baseUrl;
}
$slug = trim((string) (getenv('TASK_CERT_VIEW_SLUG') ?: ($_ENV['TASK_CERT_VIEW_SLUG'] ?? 'tasks-cert')));
$slug = strtolower($slug);
$slug = preg_replace('/[^a-z0-9_-]/', '', $slug) ?? '';
if ($slug === '') {
$slug = 'tasks-cert';
}
$baseUrl = '/' . task_cert_site_code() . '/' . task_cert_language_code() . '/' . $slug;
return $baseUrl;
}
function task_cert_view_url(string $viewKey = 'admin.task_list', array $query = []): string
{
$viewMap = [
'admin.task_list' => 'admin-task-list',
'admin.task_form' => 'edit',
'admin.task_detail' => 'view',
'admin.approval_queue' => 'approvals',
'admin.escalation_dashboard' => 'escalations',
'admin.task_categories' => 'categories',
'user.my_tasks' => 'my-tasks',
'user.task_detail' => 'task',
'user.overdue_widget' => 'overdue-widget',
'user.summary_widget' => 'summary-widget',
'user.escalation_overlay_widget' => 'escalation-overlay-widget',
];
$relativePath = $viewMap[$viewKey] ?? trim($viewKey);
$base = rtrim(task_cert_view_base_url(), '/');
$cleanPath = trim((string) $relativePath, '/');
$url = $base;
if ($cleanPath !== '') {
$url .= '/' . $cleanPath . '/';
} else {
$url .= '/';
}
if ($viewKey === 'admin.task_detail' && !array_key_exists('route_guard', $query)) {
// Guard param prevents monolith route edge cases when URL ends with numeric query values.
$query['route_guard'] = 'load/';
}
if ($query !== []) {
$queryString = http_build_query($query);
if ($queryString !== '') {
$url .= '?' . $queryString;
}
}
return $url;
}
function task_cert_url(string $relativePath = ''): string
{
$base = task_cert_base_url();
$cleanPath = ltrim(trim($relativePath), '/');
if ($cleanPath === '') {
return $base === '' ? '/' : $base;
}
return ($base === '' ? '' : $base) . '/' . $cleanPath;
}
function task_cert_asset_versioned_url(string $relativeAssetPath): string
{
$cleanPath = ltrim($relativeAssetPath, '/');
$absolutePath = __DIR__ . '/assets/' . $cleanPath;
$version = file_exists($absolutePath) ? (string) filemtime($absolutePath) : (string) time();
return task_cert_asset_url($cleanPath) . '?v=' . rawurlencode($version);
}
function task_cert_render_styles(array $variants = [], array $extraFiles = []): void
{
static $printed = [];
$files = [
'css/task_cert.css',
];
if (in_array('admin', $variants, true)) {
$files[] = 'css/task_cert_admin.css';
}
if (in_array('user', $variants, true)) {
$files[] = 'css/task_cert_user.css';
}
$files = array_values(array_unique(array_merge($files, $extraFiles)));
foreach ($files as $file) {
if (isset($printed[$file])) {
continue;
}
$href = task_cert_asset_versioned_url($file);
echo '<link rel="stylesheet" href="' . htmlspecialchars($href, ENT_QUOTES, 'UTF-8') . '">' . PHP_EOL;
$printed[$file] = true;
}
}
function task_cert_render_scripts(array $files): void
{
static $printed = [];
foreach ($files as $file) {
$cleanFile = ltrim((string) $file, '/');
if ($cleanFile === '' || isset($printed[$cleanFile])) {
continue;
}
$src = task_cert_asset_versioned_url($cleanFile);
echo '<script src="' . htmlspecialchars($src, ENT_QUOTES, 'UTF-8') . '" defer></script>' . PHP_EOL;
$printed[$cleanFile] = true;
}
}

View File

@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
final class TaskCertCronRuntime
{
public static function parseOptions(array $argv, bool $allowTaskId = true): array
{
$options = [
'apply' => false,
'dry_run' => false,
'json' => false,
'no_lock' => false,
'task_id' => null,
'help' => false,
];
$count = count($argv);
for ($i = 1; $i < $count; $i++) {
$arg = (string) $argv[$i];
if ($arg === '--apply') {
$options['apply'] = true;
continue;
}
if ($arg === '--dry-run') {
$options['dry_run'] = true;
continue;
}
if ($arg === '--json') {
$options['json'] = true;
continue;
}
if ($arg === '--no-lock') {
$options['no_lock'] = true;
continue;
}
if ($arg === '--help') {
$options['help'] = true;
continue;
}
if (strpos($arg, '--task_id=') === 0) {
if (!$allowTaskId) {
throw new InvalidArgumentException('--task_id wird für diesen Job nicht unterstützt.');
}
$raw = trim(substr($arg, strlen('--task_id=')));
if ($raw === '' || !ctype_digit($raw)) {
throw new InvalidArgumentException('Ungültiger Wert für --task_id.');
}
$options['task_id'] = (int) $raw;
continue;
}
if ($arg === '--task_id') {
if (!$allowTaskId) {
throw new InvalidArgumentException('--task_id wird für diesen Job nicht unterstützt.');
}
$next = $argv[$i + 1] ?? null;
if (!is_string($next) || trim($next) === '' || !ctype_digit(trim($next))) {
throw new InvalidArgumentException('Ungültiger Wert für --task_id.');
}
$options['task_id'] = (int) trim($next);
$i++;
continue;
}
throw new InvalidArgumentException('Unbekannte Option: ' . $arg);
}
if ($options['apply'] && $options['dry_run']) {
throw new InvalidArgumentException('--apply und --dry-run können nicht gleichzeitig gesetzt werden.');
}
if ($options['task_id'] !== null && (int) $options['task_id'] <= 0) {
throw new InvalidArgumentException('--task_id muss > 0 sein.');
}
return $options;
}
public static function runWithHandling(string $jobName, array $options, callable $runner): int
{
$startedAt = microtime(true);
$runId = self::buildRunId($jobName);
$mode = $options['apply'] ? 'apply' : 'dry_run';
$lockHandle = null;
$basePayload = [
'timestamp' => date('c'),
'job' => $jobName,
'job_run_id' => $runId,
'mode' => $mode,
'task_id' => $options['task_id'],
];
try {
if (!$options['no_lock']) {
$lockResult = self::acquireLock($jobName);
$lockHandle = $lockResult['handle'] ?? null;
$acquired = (bool) ($lockResult['acquired'] ?? false);
if (!$acquired) {
$payload = $basePayload + [
'success' => false,
'skipped' => true,
'skip_reason' => 'lock_active',
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
];
self::writeCronLog($payload);
if ((bool) ($options['json'] ?? false)) {
self::emitJson($payload);
} else {
fwrite(STDOUT, '[SKIP] ' . $jobName . ': Lock aktiv, Job wird übersprungen.' . PHP_EOL);
}
return 3;
}
}
$context = [
'job_run_id' => $runId,
'mode' => $mode,
'task_id' => $options['task_id'],
];
$summary = $runner($context);
if (!is_array($summary)) {
$summary = [];
}
$payload = $basePayload + [
'success' => true,
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
'summary' => $summary,
];
self::writeCronLog($payload);
if ((bool) ($options['json'] ?? false)) {
self::emitJson($payload);
} else {
fwrite(STDOUT, '[OK] ' . $jobName . ' (' . $mode . ') abgeschlossen in ' . $payload['duration_ms'] . 'ms.' . PHP_EOL);
}
return 0;
} catch (Throwable $throwable) {
$payload = $basePayload + [
'success' => false,
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
'error' => $throwable->getMessage(),
];
self::writeCronLog($payload);
if ((bool) ($options['json'] ?? false)) {
self::emitJson($payload);
} else {
fwrite(STDERR, '[ERROR] ' . $jobName . ' (' . $mode . '): ' . $throwable->getMessage() . PHP_EOL);
}
return 1;
} finally {
self::releaseLock($lockHandle);
}
}
public static function printUsage(string $usage): void
{
fwrite(STDOUT, trim($usage) . PHP_EOL);
}
public static function printOptionError(string $message, string $usage): void
{
fwrite(STDERR, $message . PHP_EOL . PHP_EOL . trim($usage) . PHP_EOL);
}
private static function acquireLock(string $jobName): array
{
$lockDir = self::logsDir() . '/locks';
if (!is_dir($lockDir)) {
@mkdir($lockDir, 0775, true);
}
$lockPath = $lockDir . '/task_cert_' . $jobName . '.lock';
$handle = @fopen($lockPath, 'c+');
if ($handle === false) {
throw new RuntimeException('Lock-Datei konnte nicht geöffnet werden: ' . $lockPath);
}
$acquired = flock($handle, LOCK_EX | LOCK_NB);
if (!$acquired) {
return [
'acquired' => false,
'handle' => $handle,
];
}
$meta = [
'pid' => getmypid(),
'started_at' => date('c'),
];
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
fflush($handle);
return [
'acquired' => true,
'handle' => $handle,
];
}
private static function releaseLock($lockHandle): void
{
if (!is_resource($lockHandle)) {
return;
}
@flock($lockHandle, LOCK_UN);
@fclose($lockHandle);
}
private static function writeCronLog(array $payload): void
{
$logDir = self::logsDir();
if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
$file = $logDir . '/task_cert_cron.log';
$line = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($line) || $line === '') {
$line = json_encode([
'timestamp' => date('c'),
'success' => false,
'error' => 'cron_log_encode_failed',
]);
}
@file_put_contents($file, $line . PHP_EOL, FILE_APPEND | LOCK_EX);
}
private static function emitJson(array $payload): void
{
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
}
private static function logsDir(): string
{
return dirname(__DIR__) . '/logs';
}
private static function buildRunId(string $jobName): string
{
try {
$entropy = bin2hex(random_bytes(6));
} catch (Throwable $throwable) {
$entropy = substr(sha1((string) microtime(true)), 0, 12);
}
return $jobName . '-' . date('YmdHis') . '-' . $entropy;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/CronRuntime.php';
$usage = <<<TXT
Usage:
php cli/task_cert_materialize_assignments.php [--no-lock] [--json]
Options:
--no-lock Locking deaktivieren (nur Debug)
--json JSON-Ausgabe zusätzlich auf stdout
--help Diese Hilfe anzeigen
TXT;
try {
$options = TaskCertCronRuntime::parseOptions($argv, false);
} catch (InvalidArgumentException $exception) {
TaskCertCronRuntime::printOptionError($exception->getMessage(), $usage);
exit(2);
}
if ((bool) ($options['help'] ?? false)) {
TaskCertCronRuntime::printUsage($usage);
exit(0);
}
$options['apply'] = true;
$exitCode = TaskCertCronRuntime::runWithHandling(
'materialize_assignments',
$options,
static function (): array {
require_once dirname(__DIR__) . '/bootstrap.php';
$services = task_cert_services();
$resolver = $services['assignmentResolverService'] ?? null;
if (!$resolver instanceof AssignmentResolverService) {
throw new RuntimeException('AssignmentResolverService nicht verfügbar.');
}
$count = $resolver->materializeAssignmentsForAllActiveTasks();
CronHeartbeat::record('materialize_assignments', ['materialized' => $count]);
return [
'job' => 'materialize_assignments',
'materialized' => $count,
];
}
);
exit($exitCode);

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/CronRuntime.php';
$usage = <<<TXT
Usage:
php cli/task_cert_recalculate_escalations.php [--apply|--dry-run] [--task_id=<id>] [--no-lock] [--json]
Options:
--apply Mutierender Lauf (Status/Eskalationslevel schreiben)
--dry-run Nur Vorschau, keine DB-Änderungen
--task_id Optional: nur eine Aufgabe verarbeiten
--no-lock Locking deaktivieren (nur Debug)
--json JSON-Ausgabe zusätzlich auf stdout
--help Diese Hilfe anzeigen
TXT;
try {
$options = TaskCertCronRuntime::parseOptions($argv, true);
} catch (InvalidArgumentException $exception) {
TaskCertCronRuntime::printOptionError($exception->getMessage(), $usage);
exit(2);
}
if ((bool) ($options['help'] ?? false)) {
TaskCertCronRuntime::printUsage($usage);
exit(0);
}
$exitCode = TaskCertCronRuntime::runWithHandling(
'recalculate_escalations',
$options,
static function () use ($options): array {
require_once dirname(__DIR__) . '/bootstrap.php';
$services = task_cert_services();
$escalationService = $services['escalationService'] ?? null;
if (!$escalationService instanceof EscalationService) {
throw new RuntimeException('EscalationService nicht verfügbar.');
}
$apply = (bool) ($options['apply'] ?? false);
$taskId = (int) ($options['task_id'] ?? 0);
$summary = $escalationService->recalculateEscalationsDetailed(
$apply,
$taskId > 0 ? $taskId : null
);
if ($apply) {
CronHeartbeat::record('recalculate_escalations', [
'changed' => (int) ($summary['changed_assignments'] ?? 0),
]);
}
return $summary;
}
);
exit($exitCode);

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/CronRuntime.php';
$usage = <<<TXT
Usage:
php cli/task_cert_sync_assignments.php [--apply|--dry-run] [--task_id=<id>] [--no-lock] [--json]
Options:
--apply Mutierender Lauf (Zuweisungen anlegen/entfernen)
--dry-run Nur Vorschau, keine DB-Änderungen
--task_id Optional: nur eine Aufgabe verarbeiten
--no-lock Locking deaktivieren (nur Debug)
--json JSON-Ausgabe zusätzlich auf stdout
--help Diese Hilfe anzeigen
TXT;
try {
$options = TaskCertCronRuntime::parseOptions($argv, true);
} catch (InvalidArgumentException $exception) {
TaskCertCronRuntime::printOptionError($exception->getMessage(), $usage);
exit(2);
}
if ((bool) ($options['help'] ?? false)) {
TaskCertCronRuntime::printUsage($usage);
exit(0);
}
$exitCode = TaskCertCronRuntime::runWithHandling(
'sync_assignments',
$options,
static function (array $context) use ($options): array {
require_once dirname(__DIR__) . '/bootstrap.php';
$services = task_cert_services();
$resolver = $services['assignmentResolverService'] ?? null;
if (!$resolver instanceof AssignmentResolverService) {
throw new RuntimeException('AssignmentResolverService nicht verfügbar.');
}
$apply = (bool) ($options['apply'] ?? false);
$taskId = (int) ($options['task_id'] ?? 0);
$auditContext = [
'changed_by' => 0,
'trigger' => 'cron_cli',
'job_run_id' => (string) ($context['job_run_id'] ?? ''),
];
if ($taskId > 0) {
$results = [$resolver->syncAssignmentsForTask($taskId, $apply, 'current_cycle', $auditContext)];
} else {
$results = $resolver->syncAssignmentsForAllActiveTasks($apply, 'current_cycle', $auditContext);
}
$totals = [
'task_count' => 0,
'target_count' => 0,
'existing_count' => 0,
'create_count' => 0,
'update_count' => 0,
'stale_count' => 0,
'protected_completed_count' => 0,
'protected_certificate_count' => 0,
'remove_count' => 0,
'remove_with_submissions_count' => 0,
];
foreach ($results as $result) {
if (!is_array($result)) {
continue;
}
$totals['task_count']++;
foreach ([
'target_count',
'existing_count',
'create_count',
'update_count',
'stale_count',
'protected_completed_count',
'protected_certificate_count',
'remove_count',
'remove_with_submissions_count',
] as $key) {
$totals[$key] += (int) ($result[$key] ?? 0);
}
}
if ($apply && $taskId <= 0) {
CronHeartbeat::record('sync_assignments', [
'task_count' => (int) ($totals['task_count'] ?? 0),
'create_count' => (int) ($totals['create_count'] ?? 0),
'remove_count' => (int) ($totals['remove_count'] ?? 0),
]);
}
return [
'job' => 'sync_assignments',
'mode' => $apply ? 'apply' : 'dry_run',
'scope' => 'current_cycle',
'task_id' => $taskId > 0 ? $taskId : null,
'totals' => $totals,
];
}
);
exit($exitCode);

View File

@@ -0,0 +1,339 @@
# Task-Cert Modul: Architektur und Lifecycle
## Zweck
Dieses Modul verwaltet Aufgaben inkl. Zielgruppen-Zuweisung, Erledigungsmethoden, Freigabeprozess, Zertifikaten, Eskalationen und Monitoring.
## Verzeichnisstruktur
- `bootstrap.php`: Initialisierung, DI, URL-Mapping, Asset-Helper.
- `pages/`: Views (Admin + User).
- `actions/`: View-Controller (Datenaufbereitung pro Seite).
- `endpoints/`: Schreibende Endpunkte (POST/CLI).
- `service/`: Business-Logik.
- `repo/`: DB-Zugriff.
- `sql/`: Schema + Migrationen.
## Zentrale Services
- `TaskDefinitionService`: Aufgabe anlegen/ändern, Reset aktueller Zyklus, Soft-/Hard-Delete, Audit-Log.
- `AssignmentResolverService`: materialisiert Zuweisungen aus Target-Regeln + Overrides.
- `CycleCalculatorService`: berechnet Zyklus (`cycle_key`) und Fälligkeit (`due_at`) für kalender- und erledigungsbasierte Wiederholung.
- `CompletionService`: verarbeitet User-Einreichungen und bewertet Methodenabschluss.
- `ApprovalService`: Admin-Freigabe/Ablehnung manueller Einreichungen.
- `CertificateService`: Zertifikate ausstellen/widerrufen/ablaufen lassen.
- `EscalationService`: setzt Überfällig-Status und Eskalationslevel.
- `TaskMonitoringService`: Admin-Detaildashboard (KPIs, Freigaben, Überschreitungen, Antworten).
## Datenmodell (Kernobjekte)
- `task_definition`: Aufgabenstammdaten inkl. Kategorie, Rhythmus, Rhythmus-Basis (`calendar|completion`), Fälligkeitslogik, Zertifikatsmodus.
- `task_category`: Kategorien inkl. Admin-CRUD (inline) und Schutzregeln.
- `task_category_change_log`: Audit-Log für Kategorieänderungen.
- `task_method` + Untertabellen:
- `task_method_knowledge_post`
- `task_form_field`
- `task_quiz_question`
- `task_quiz_option`
- `task_target_rule` + `task_target_override`: Zielgruppenlogik.
- `task_assignment`: Instanz pro Mitarbeiter + Zyklus (Status, Due-Date, Eskalation).
- `task_submission`: Einreichungen pro Methode.
- `task_certificate`: Zertifikate pro Assignment.
- `task_reminder_policy`: Eskalationsregeln.
- `task_escalation_event`: Historie der Eskalationslevel-Events.
- `task_change_log`: Audit-Log für Task-Konfigurationsänderungen.
Referenz: `sql/init.sql`
## Statusmodelle
### Assignment-Status (`task_assignment.status`)
- `open` -> `in_progress` -> `completed`
- Sonder-/Nebenpfade: `approval_pending`, `overdue`, `rejected`, `expired`
### Submission-Status (`task_submission.status`)
- `submitted`, `auto_passed`, `awaiting_approval`, `approved`, `rejected`
Wichtig:
- `escalation_level = 0` bedeutet nur „keine aktive Eskalationsstufe“.
- Ein Assignment kann trotzdem überfällig sein (z. B. `status = overdue`, Eskalation noch nicht gestartet).
## Lifecycle Ende-zu-Ende
### 1) Aufgabe erstellen/ändern (Admin)
1. Formular: `pages/admin/task_form.php`
2. Endpoint: `endpoints/admin/task_upsert.php`
3. Service:
- `TaskDefinitionService::createTask()` oder `updateTask()`
- Speichert Definition + Regeln (`TaskRuleRepository::replaceTaskRules`)
- Schreibt Audit-Eintrag (`task_change_log`)
4. Danach Materialisierung:
- `AssignmentResolverService::materializeAssignmentsForTask()`
### 2) Zuweisungen materialisieren
`AssignmentResolverService`:
- Lädt aktive Aufgabe + Rhythmus-Kontext.
- Ermittelt Zielkontakte über `task_target_rule` und `task_target_override`.
- Erzeugt/aktualisiert `task_assignment` mit `cycle_key`, `cycle_start_at`, `cycle_end_at`, `due_at`.
- Modus `calendar`: wie bisher zykluskalenderbasiert.
- Modus `completion`: pro Mitarbeiter individueller Folgeturnus ab letzter Erledigung (ohne Vorziehen bei früher Erledigung).
Zusätzlicher Sammel-Endpoint:
- `endpoints/admin/materialize_assignments.php` (POST/CLI): für alle aktiven Aufgaben.
### 3) Nutzer bearbeitet Aufgabe
User-Endpunkte:
- `endpoints/user/submit_read.php`
- `endpoints/user/submit_form.php`
- `endpoints/user/submit_quiz.php`
- `endpoints/user/request_manual_approval.php`
`CompletionService`:
- validiert Assignment + Methode + Berechtigung,
- legt `task_submission` an,
- entscheidet danach über Assignment-Fortschritt (`finalizeAssignmentState`).
### 4) Freigabeprozess
Bei manueller Freigabe:
- Submission wird mit `awaiting_approval` angelegt.
- Admin entscheidet über `endpoints/admin/approval_decision.php`.
- `ApprovalService::approveSubmission()` -> `CompletionService::refreshAssignmentAfterReview()`.
- `ApprovalService::rejectSubmission()` -> Assignment wird `rejected`.
### 5) Abschluss und Zertifikat
Wenn erforderliche Methoden erfüllt sind (`fulfillment_mode = all|any`):
- Assignment wird `completed`.
- `CertificateService::issueForAssignment()` erstellt Zertifikat, falls aktiviert.
- Bei Zertifikatsmodus `cycle` gilt:
- `valid_until = assignment.cycle_end_at`
- dadurch ist die Gültigkeit automatisch an die gewählte Rhythmus-Basis gekoppelt.
## Rhythmus-Basis bei wiederkehrenden Aufgaben
- `calendar`:
- Zyklus folgt festen Kalenderintervallen ab `start_date`.
- `completion`:
- pro Mitarbeiter individuell,
- neue Periode erst nach `completed_at`,
- kein Vorziehen bei früher Erledigung,
- erste Periode für neu zugewiesene Mitarbeiter startet am Zuweisungstag (bzw. nicht vor `start_date`).
Hinweis fürs Admin-Monitoring:
- Für `completion` zeigt das Dashboard den aktuellen Stand je Mitarbeiter (ohne Cycle-Key-Auswahl).
### 6) Überfälligkeit und Eskalation
`EscalationService::recalculateEscalations()`:
- setzt bei überfälligen Assignments `status = overdue`,
- berechnet Eskalationsstufe nach `task_reminder_policy`:
- `first_escalation_days`
- `step_days`
- `max_level`
- schreibt Events nach `task_escalation_event`.
Aktueller Betriebsmodus:
- Recalc läuft nur bei explizitem Trigger (Admin-Button oder Endpoint):
- `actions/admin/escalation_dashboard_action.php` via `?recalc=1`
- `endpoints/admin/recalculate_escalations.php` (POST/CLI)
- Für Automatik ist ein externer Job (z. B. Cron) nötig.
### 7) Reset / Löschen
#### Reset aktueller Zyklus
`TaskDefinitionService::resetCurrentCycleProgress()`:
- widerruft Zertifikate (`task_certificate.status = revoked`),
- löscht Einreichungen,
- setzt Assignments zurück (`status=open`, `overdue_at=NULL`, `escalation_level=0`, `last_escalated_at=NULL`),
- schreibt Audit-Eintrag `reset_cycle_progress`.
Hinweis:
- Wenn `due_at` weiterhin in der Vergangenheit liegt, kann eine spätere Eskalations-Neuberechnung wieder `overdue`/Eskalation setzen.
#### Soft Delete
- Endpoint: `endpoints/admin/task_delete.php`
- Wirkung: Aufgabe deaktivieren (`active = 0`), Daten bleiben erhalten.
#### Hard Delete
- Endpoint: `endpoints/admin/task_delete_hard.php`
- Wirkung: vollständiges Löschen inkl. abhängiger Datensätze (per FK-Cascade).
## Audit / Nachvollziehbarkeit
`task_change_log` wird derzeit für folgende Actions geschrieben:
- `create`
- `update`
- `reset_cycle_progress`
- `soft_delete`
- `hard_delete`
Payloads:
- `before_json`, `after_json`, `diff_json`
- Zugriff über `TaskChangeLogRepository` (derzeit ohne dedizierte Admin-UI).
## Views und Routen
Routen-Mapping in `bootstrap.php` (`task_cert_view_url`):
- Admin:
- `admin-task-list`
- `edit`
- `view`
- `approvals`
- `escalations`
- `categories`
- User:
- `my-tasks`
- `task`
- `overdue-widget`
## CLI/Cron Beispiele
Alle Beispiele gehen davon aus, dass der Job im Modul-Verzeichnis ausgeführt wird:
`/tasks`
### Cron-CLI Jobs
#### 1) Assignments materialisieren
```bash
php cli/task_cert_materialize_assignments.php
```
Optional:
- `--no-lock` (nur Debug)
- `--json` (Summary zusätzlich auf stdout)
Operation ist idempotent (kein --apply nötig).
#### 2) Zuweisungen synchronisieren
```bash
php cli/task_cert_sync_assignments.php --apply
```
Optional:
- `--dry-run` (keine Änderungen schreiben)
- `--task_id=123` (nur eine Aufgabe)
- `--no-lock` (nur Debug)
- `--json` (Summary zusätzlich auf stdout)
#### 3) Eskalationen neu berechnen
```bash
php cli/task_cert_recalculate_escalations.php --apply
```
Optional:
- `--dry-run` (keine Änderungen schreiben)
- `--task_id=123` (nur eine Aufgabe)
- `--no-lock` (nur Debug)
- `--json` (Summary zusätzlich auf stdout)
### Exit-Codes der CLI Jobs
- `0`: Erfolg
- `1`: Lauf mit Fehler beendet
- `2`: Ungültige CLI-Parameter
- `3`: Lauf übersprungen (Lock bereits aktiv)
### CLI-Ausgabe
Jeder Lauf schreibt eine Zeile auf stdout bzw. stderr:
- `[OK] sync_assignments (apply) abgeschlossen in 312ms.`
- `[SKIP] sync_assignments: Lock aktiv, Job wird übersprungen.`
- `[ERROR] sync_assignments (apply): Fehlermeldung`
### Locking und Cron-Log
- Lockfiles:
- `logs/locks/task_cert_materialize_assignments.lock`
- `logs/locks/task_cert_sync_assignments.lock`
- `logs/locks/task_cert_recalculate_escalations.lock`
- Separates Cron-Log (JSON-Lines):
- `logs/task_cert_cron.log`
### Cron-Empfehlung
```cron
# Assignments materialisieren: alle 15 Minuten
*/15 * * * * cd /tasks && php cli/task_cert_materialize_assignments.php
# Eskalationen berechnen: alle 30 Minuten
*/30 * * * * cd /tasks && php cli/task_cert_recalculate_escalations.php --apply
# Zuweisungs-Sync: täglich um 02:00
0 2 * * * cd /tasks && php cli/task_cert_sync_assignments.php --apply
```
### Legacy-Endpoints (nur noch als HTTP-Endpoints für Admin-UI)
- `endpoints/admin/materialize_assignments.php`
- `endpoints/admin/sync_assignments.php`
- `endpoints/admin/recalculate_escalations.php`
Diese sollten **nicht mehr per Cron** aufgerufen werden (kein Locking, keine CLI-Ausgabe).
## Betriebsempfehlungen
1. Für produktiven Betrieb einen periodischen Recalc-Job einrichten:
- `cli/task_cert_sync_assignments.php`
- `cli/task_cert_recalculate_escalations.php`
2. Zeitzonen konsistent halten (Schema nutzt UTC-Default).
3. Vor Migrationen Backup einplanen (insb. Kategorie- und Lifecycle-Migrationen).
4. Bei UI-Entscheidungen klar trennen:
- „Überfällig“ vs. „Eskalation“ (nicht dasselbe).
## Schnelle Troubleshooting-Checks
- Eskalation fehlt:
- `reminder_active = 1`?
- `due_at` gesetzt und in Vergangenheit?
- Recalc ausgelöst?
- In Eskalationsliste „zu viel“ sichtbar:
- Prüfen, ob Filter auf `escalation_level > 0` gesetzt ist.
- Reset scheinbar ohne Effekt:
- Prüfen, ob `due_at` weiterhin alt ist und Recalc erneut `overdue` setzt.
## Neue Features (Stand 2026-03-18)
### Karenzzeit (Grace Period)
- **Feld:** `task_reminder_policy.grace_days` (Default: 7)
- **Feld:** `task_assignment.grace_until` (berechnet bei Erstellung: `now() + grace_days`)
- **Logik:** Eskalation greift erst wenn `now > max(due_at, grace_until)`
- **Zweck:** Verhindert sofortige Eskalation bei Späteinsteigern im Zyklus
- **Migration:** `sql/migrations/2026-03-18_add_grace_period.sql`
### Formular-Vorbefüllung (Prefill)
- **Feld:** `task_form_field.prefill_column` (varchar, nullable)
- **Whitelist:** `lib/ContactPrefillColumns.php` definiert erlaubte `main_contact`-Spalten
- **Aktuell erlaubt:** `name`, `email`, `vehicle_registration_place`
- **Verhalten:** Bei Typ `text` wird der Wert aus dem Kontakt des Nutzers als Default gesetzt (überschreibbar)
- **Migration:** `sql/migrations/2026-03-18_add_form_field_prefill.sql`
### Verantwortliche (Responsible Contacts)
- **Tabelle:** `task_responsible` (N:M zwischen `task_definition` und `main_contact`)
- **Admin:** Multiselect im Stammdaten-Tab
- **User:** Ansprechpartner-Section im Aufgaben-Detail (Name + E-Mail)
- **Migration:** `sql/migrations/2026-03-17_add_task_responsible.sql`
### Completion-Guard
- Backend: `CompletionService::validateContext()` blockiert Einreichungen bei `status = completed|expired`
- Frontend: Alle Submit-Buttons disabled bei abgeschlossenen Assignments
### Sofortige Folgezyklus-Erstellung
- Bei erledigungsbasierten Aufgaben erstellt `CompletionService::finalizeAssignmentState()` nach Completion direkt den nächsten Zyklus
- Deduplizierung: `createIfNotExists` + DB Unique-Key verhindert Doppel-Erstellung durch Cron
### Cron-Heartbeat-Monitoring
- `lib/CronHeartbeat.php`: Schreibt JSON-Dateien nach `logs/heartbeat/` bei erfolgreichem Cron-Lauf
- Admin-Aufgabenliste zeigt letzten erfolgreichen Lauf pro Job
- Jobs: `materialize_assignments`, `recalculate_escalations`, `sync_assignments`
### Einreichungs-Historie
- User sieht alle bisherigen Einreichungen (nicht nur die letzte) im Aufgaben-Detail
- Neueste Einreichung direkt sichtbar, ältere aufklappbar
- Formular-Antworten formatiert als Key-Value-Liste (nicht JSON)
### Lesestatus-Persistenz
- Bei Wissensbeiträgen: bereits gelesene Beiträge bleiben im Select vorausgewählt
- Grüner "Gelesen"-Badge neben dem Beitragstitel
### Admin-Monitoring Übersicht
- Fortschrittsbalken statt 8 KPI-Kacheln
- Handlungsbedarf-Cards nur bei Überfällig/Freigaben/Eskalationen
- Rhythmus-Badge (Kalender/Erledigungsbasiert) in Task-Liste und Detail-Header
### Mitarbeiter-Aufgabensuche (Admin)
- Kontakt-Dropdown in der Admin-Aufgabenliste
- Zeigt alle Assignments des gewählten Mitarbeiters mit Status, Daten, Zyklus
- Default: Erster Kontakt vorausgewählt
## Utility-Klassen
### `lib/ContactPrefillColumns.php`
Zentrale Whitelist für vorbefüllbare Kontaktspalten.
- `MAP`: Spaltenname → Label (für Admin-Dropdown)
- `sanitize()`: Validiert gegen Whitelist
- `resolve()`: Holt Wert aus Kontakt-Array
- Erweiterbar: Neue Spalten in `MAP` ergänzen
### `lib/CronHeartbeat.php`
Dateibasiertes Monitoring für Cron-Jobs.
- `record($jobName, $summary)`: Schreibt `logs/heartbeat/{job}.json`
- `readAll()`: Liest alle Heartbeats für Admin-Anzeige
- Kein DB-Schema nötig

View File

@@ -0,0 +1,29 @@
# Task-Cert: Test-Cases (V1)
Diese Tabelle ist als schnelle Abnahme- und Regression-Checkliste gedacht.
| # | Praxisfall (kurz) | Typ | Konfiguration (Beispiel) | Testschritte | Erwartetes Ergebnis |
|---|---|---|---|---|---|
| 1 | Erstunterweisung Arbeitssicherheit | Einmalig | Startdatum `2026-03-01`, Fristregel `Zyklusende`, Zertifikat `aus` | Aufgabe anlegen, Zuweisung materialisieren | Genau eine Zuweisung mit `cycle_key=once`, Status `open`, kein Zertifikat |
| 2 | Einmaliger Pflicht-Read mit Frist | Einmalig | Startdatum `2026-03-01`, Fristregel `X Tage nach Startdatum`, `5` Tage, Zertifikat `aus` | Aufgabe anlegen, Zuweisung prüfen | `due_at = 2026-03-06 23:59:59` (tagesgenau je Implementierung), Status nicht `completed` |
| 3 | Monatliche Hygiene-Schulung | Wiederkehrend (kalenderbasiert) | `monthly`, Intervall `1`, Startdatum `2026-01-01`, Frist `+5 Tage`, Zertifikat `Zyklus` | Aufgabe anlegen, Abschluss im Januar durchführen, nächste Periode prüfen | Februar-Periode startet kalenderbasiert, neue Frist wieder `+5 Tage`; Zertifikat Januar gültig bis Ende Januar-Zyklus |
| 4 | Wöchentliche Team-Checkliste | Wiederkehrend (kalenderbasiert) | `weekly`, Intervall `1`, Startdatum `2026-03-02` (Montag), Fristregel `Wochentag=5` (Freitag) | Aufgabe anlegen, aktuelle Woche prüfen | `due_at` liegt auf Freitag derselben Zykluswoche |
| 5 | Geräteeinweisung mit individuellem Rhythmus | Wiederkehrend (erledigungsbasiert) | `monthly`, Basis `completion`, Startdatum `2026-01-01`, Frist `+5 Tage` | Periode 1 früh am `2026-01-10` abschließen | Nächster Start wird **nicht vorgezogen**; Start bleibt mindestens beim geplanten Intervallstart |
| 6 | Verspätete Nachschulung | Wiederkehrend (erledigungsbasiert) | `monthly`, Basis `completion`, Startdatum `2026-01-01`, Frist `+5 Tage` | Periode 1 spät am `2026-02-20` abschließen | Nächste Periode startet ab Abschlussanker (`2026-02-20`), also nach hinten verschoben |
| 7 | Blockierende Periode ohne Abschluss | Wiederkehrend (erledigungsbasiert) | wie Fall 5 | Periode 1 **nicht** abschließen, Materialisierung erneut ausführen | Keine neue Periode wird angelegt, solange letzte Periode nicht `completed` ist |
| 8 | Zertifikat 30 Tage gültig | Zertifikat (tagebasiert) | Zertifikat `an`, Validität `days=30` | Aufgabe abschließen, Zertifikat prüfen | Zertifikat wird ausgestellt und `valid_until` liegt 30 Tage nach Ausstellung |
| 9 | Dauerzertifikat bei Wiederholung | Zertifikat (dauerhaft) | Zertifikat `an`, Validität `permanent`, wiederkehrende Aufgabe | Zwei Perioden nacheinander abschließen | Älteres Zertifikat bleibt gültig (`valid_until` leer/NULL), neues Zertifikat kommt zusätzlich dazu |
| 10 | Einzelfall korrigieren im Admin | Admin Einzel-Reset (Erledigt-Tab) | Abgeschlossene Zuweisung mit Einreichung + Zertifikat vorhanden | In `Erledigt` Aktion `Zurücksetzen` ausführen | Für diese Assignment-ID: Einreichungen gelöscht, Zertifikat widerrufen, Zuweisung wieder `open`, Erfolgsmeldung im Tab |
| 11 | Aufgabe erst ab Startdatum sichtbar | Sichtbarkeit/Startdatum | Startdatum in Zukunft, z. B. `2026-08-01` | Vor und am Startdatum im User-View prüfen | Vor Startdatum keine sichtbare offene Zuweisung; ab Startdatum sichtbar |
| 12 | Mitarbeiter wechselt Abteilung | Zielgruppen-Sync | Zielregel z. B. Abteilung A; Nutzer wechselt von A nach B | `Zuweisungsregeln anwenden` (dry-run + apply) | Nutzer ohne Match wird entfernt, neue Treffer werden angelegt; Include-Override bleibt erhalten |
| 13 | Reminder ab Tag 0 | Eskalation/Reminder | `first_escalation_days=0`, `step_days=7`, Reminder aktiv | Überfällige Assignment neu berechnen | Erste Eskalationsstufe greift sofort bei Überfälligkeit (ohne 1 Tag Wartezeit) |
| 14 | Manuelle Freigabe mit Ablehnung | Freigabeprozess | Methode `manual_approval`, Submission offen | Im Freigaben-Tab zuerst `Ablehnen`, danach erneut einreichen und `Freigeben` | Statuswechsel korrekt: `awaiting_approval -> rejected -> approved/completed`, Zertifikat erst nach finaler Freigabe |
| 15 | Zykluswechsel mit altem Key | Admin-Monitoring (kalenderbasiert) | Aufgabe mit >24 historischen Zyklen, URL enthält gültigen alten `cycle_key` | `view?id=<task_id>&cycle_key=<alter_key>` aufrufen | Angeforderter Zyklus bleibt selektiert, KPIs/Tabellen laden Daten genau dieses Zyklus |
| 16 | Antworten exportieren (Zyklus) | Admin-Monitoring Export | Kalenderbasierte Aufgabe mit Einreichungen im gewählten Zyklus | In `view` Zyklus wählen, Button `Antworten exportieren` klicken | Download startet als CSV (`;`, UTF-8 BOM), eine Zeile pro Einreichung inkl. `cycle_key`, Kontakt, Methode, Status, Zeit, Punkte, `answer_summary`, `answer_detail_json` |
| 17 | Antworten exportieren (Snapshot) | Admin-Monitoring Export | Erledigungsbasierte Aufgabe (`recurrence_basis=completion`) mit aktuellen Einreichungen | In `view` Button `Antworten exportieren` klicken | Download startet als Snapshot-CSV (latest-Stand), ohne Zyklus-Dropdown, aber mit Antworten der aktuellen Latest-Zuweisungen |
## Hinweise zur Durchführung
- Vor jedem Testfall mit festen Startwerten arbeiten (eigene Testaufgabe je Fall).
- Bei wiederkehrenden Fällen Materialisierung und Eskalations-Recalc gezielt auslösen.
- Für reproduzierbare Ergebnisse mit festen Datumswerten testen (keine relativen Angaben wie "heute").

View File

@@ -0,0 +1,195 @@
# Cronjobs — Task-Modul
Das Task-Modul benötigt **drei Cronjobs**, die regelmäßig laufen müssen:
1. **Assignments materialisieren** — erstellt neue Aufgaben-Zuweisungen für aktive Zyklen
2. **Assignments synchronisieren** — gleicht Zuweisungen mit aktuellen Zielgruppen-Regeln ab
3. **Eskalationen berechnen** — markiert überfällige Aufgaben und erhöht Eskalationsstufen
Alle CLI-Skripte liegen in `cli/` und nutzen `CronRuntime` mit:
- **Locking** (verhindert parallele Läufe)
- **Strukturiertes Log** (`logs/task_cert_cron.log`, JSON-Lines)
- **Heartbeat** (`logs/heartbeat/{job}.json` für Admin-UI)
- **Exit-Codes** (0 = OK, 1 = Fehler, 2 = ungültige Parameter, 3 = Lock aktiv)
- **CLI-Ausgabe** (`[OK]`/`[SKIP]`/`[ERROR]` auf stdout/stderr)
---
## 1. Assignments materialisieren
Erstellt für alle aktiven Aufgaben die Zuweisungen im aktuellen Zyklus (z.B. neuer Monat = neue Assignments).
```bash
php cli/task_cert_materialize_assignments.php
```
**Optionen:**
- `--no-lock` — Locking deaktivieren (nur Debug)
- `--json` — JSON-Ausgabe zusätzlich auf stdout
- `--help` — Hilfe anzeigen
**Empfohlenes Intervall:** alle 15 Minuten
**Was passiert:**
- Ermittelt den aktuellen Zyklus jeder aktiven Aufgabe (Tag/Woche/Monat/Jahr)
- Erstellt fehlende Assignments für alle Zielkontakte
- Bei completion-based Tasks: erstellt den nächsten Zyklus nach Abschluss
- Operation ist idempotent (kein --apply nötig)
---
## 2. Assignments synchronisieren
Gleicht bestehende Assignments mit den aktuellen Zielgruppen-Regeln ab. Nützlich wenn sich Abteilungszugehörigkeiten oder Rollen ändern.
```bash
# Alle aktiven Aufgaben (Anwenden)
php cli/task_cert_sync_assignments.php --apply
# Dry-Run (nur Vorschau)
php cli/task_cert_sync_assignments.php --dry-run
# Einzelne Aufgabe
php cli/task_cert_sync_assignments.php --apply --task_id=42
```
**Optionen:**
- `--apply` — Mutierender Lauf (Zuweisungen anlegen/entfernen)
- `--dry-run` — Nur Vorschau, keine DB-Änderungen
- `--task_id=<id>` — Optional: nur eine Aufgabe verarbeiten
- `--no-lock` — Locking deaktivieren (nur Debug)
- `--json` — JSON-Ausgabe zusätzlich auf stdout
- `--help` — Hilfe anzeigen
**Empfohlenes Intervall:** täglich nachts (z.B. 02:00)
**Was passiert:**
- Vergleicht Soll-Zielgruppe (Regeln + Overrides) mit Ist-Zuweisungen
- Erstellt fehlende Assignments, entfernt veraltete
- Schützt abgeschlossene Assignments und solche mit Zertifikaten vor Löschung
---
## 3. Eskalationen berechnen
Prüft alle offenen Assignments auf Überfälligkeit und berechnet Eskalationsstufen.
```bash
# Alle Aufgaben (Anwenden)
php cli/task_cert_recalculate_escalations.php --apply
# Dry-Run
php cli/task_cert_recalculate_escalations.php --dry-run
# Einzelne Aufgabe
php cli/task_cert_recalculate_escalations.php --apply --task_id=42
```
**Optionen:**
- `--apply` — Mutierender Lauf (Status/Eskalationslevel schreiben)
- `--dry-run` — Nur Vorschau, keine DB-Änderungen
- `--task_id=<id>` — Optional: nur eine Aufgabe verarbeiten
- `--no-lock` — Locking deaktivieren (nur Debug)
- `--json` — JSON-Ausgabe zusätzlich auf stdout
- `--help` — Hilfe anzeigen
**Empfohlenes Intervall:** alle 30 Minuten
**Was passiert:**
- Prüft `due_at` aller offenen/laufenden Assignments
- Setzt `status = 'overdue'` wenn Deadline überschritten
- Berechnet `escalation_level` anhand der Erinnerungsrichtlinie:
- `first_escalation_days`: Tage nach Fälligkeit bis erste Eskalation
- `step_days`: Tage zwischen Eskalationsstufen
- `max_level`: Maximale Eskalationsstufe
- Protokolliert Eskalations-Events in `task_escalation_event`
- Setzt Eskalation zurück wenn Aufgabe nicht mehr überfällig ist
---
## Crontab-Einrichtung
```crontab
# Assignments materialisieren — alle 15 Minuten
*/15 * * * * cd /var/www/intranet/html/module/tasks && /usr/bin/php cli/task_cert_materialize_assignments.php >> /var/www/intranet/html/module/tasks/logs/task_cert_cron.log 2>&1
# Eskalationen berechnen — alle 30 Minuten
*/30 * * * * cd /var/www/intranet/html/module/tasks && /usr/bin/php cli/task_cert_recalculate_escalations.php --apply >> /var/www/intranet/html/module/tasks/logs/task_cert_cron.log 2>&1
# Assignments synchronisieren — täglich um 02:00
0 2 * * * cd /var/www/intranet/html/module/tasks && /usr/bin/php cli/task_cert_sync_assignments.php --apply >> /var/www/intranet/html/module/tasks/logs/task_cert_cron.log 2>&1
```
> **Hinweis:** Den Pfad `/var/www/intranet/html/module/tasks` ggf. an die tatsächliche Installation anpassen. PHP-Binary-Pfad mit `which php` prüfen.
---
## Reihenfolge
Wenn alle Jobs gleichzeitig laufen sollen, ist die empfohlene Reihenfolge:
1. `task_cert_materialize_assignments.php` — erst Assignments anlegen
2. `task_cert_sync_assignments.php --apply` — dann synchronisieren
3. `task_cert_recalculate_escalations.php --apply` — dann Eskalationen berechnen
Bei getrennten Intervallen (wie oben) ist die Reihenfolge unkritisch.
---
## Exit-Codes
| Code | Bedeutung |
|------|-----------|
| 0 | Erfolg |
| 1 | Fehler (Exception) |
| 2 | Ungültige CLI-Parameter |
| 3 | Lock aktiv, Job übersprungen |
---
## Logging
### CLI-Ausgabe (stdout/stderr)
Jeder Lauf schreibt eine Zeile auf stdout bzw. stderr:
- `[OK] sync_assignments (apply) abgeschlossen in 312ms.`
- `[SKIP] sync_assignments: Lock aktiv, Job wird übersprungen.`
- `[ERROR] sync_assignments (apply): DB-Verbindung fehlgeschlagen`
Bei `--json` wird stattdessen ein JSON-Objekt auf stdout ausgegeben.
### Strukturiertes Log
Alle Läufe (Erfolg, Fehler, Skip) werden in `logs/task_cert_cron.log` als JSON-Lines geschrieben — unabhängig von der Crontab-Umleitung.
### Locking
Lockfiles liegen in `logs/locks/`:
- `task_cert_materialize_assignments.lock`
- `task_cert_sync_assignments.lock`
- `task_cert_recalculate_escalations.lock`
---
## Monitoring (Heartbeat)
Jeder erfolgreiche Apply-Lauf schreibt einen Timestamp nach `logs/heartbeat/{job}.json`.
**Status prüfen:**
```bash
cat logs/heartbeat/materialize_assignments.json
cat logs/heartbeat/recalculate_escalations.json
cat logs/heartbeat/sync_assignments.json
```
**Admin-Ansicht:** Die Admin-Aufgabenliste zeigt automatisch den letzten erfolgreichen Lauf jedes Jobs am unteren Rand an. Wenn ein Job noch nie gelaufen ist, wird dies rot markiert.
**Hinweis:** Heartbeat-Dateien werden nur bei **erfolgreichem** Lauf geschrieben. Fehler sind am Ausbleiben des Timestamps erkennbar.
---
## Legacy-Endpoints
Die alten Endpoint-Skripte (`endpoints/admin/`) bleiben als HTTP-Endpoints für die Admin-UI erhalten, sollten aber **nicht mehr per Cron aufgerufen** werden:
- `endpoints/admin/materialize_assignments.php`
- `endpoints/admin/sync_assignments.php`
- `endpoints/admin/recalculate_escalations.php`
Diese haben kein Locking und keine strukturierte CLI-Ausgabe.

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!TaskCertRequest::isPost()) {
TaskCertResponse::json(['success' => false, 'error' => 'Methode nicht erlaubt'], 405);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::json(['success' => false, 'error' => 'Keine Berechtigung'], 403);
}
$contentType = trim((string) ($_SERVER['CONTENT_TYPE'] ?? ''));
$isJson = strpos($contentType, 'application/json') !== false;
if ($isJson) {
$rawBody = file_get_contents('php://input');
$body = json_decode($rawBody ?: '{}', true);
if (!is_array($body)) {
$body = [];
}
$csrfToken = trim((string) ($body['csrf_token'] ?? ''));
if ($csrfToken !== '') {
$_POST['csrf_token'] = $csrfToken;
}
TaskCertCsrf::assertValidFromRequest(true);
$submissionId = (int) ($body['submission_id'] ?? 0);
$decision = trim((string) ($body['decision'] ?? ''));
$note = trim((string) ($body['note'] ?? ''));
} else {
TaskCertCsrf::assertValidFromRequest(true);
$submissionId = TaskCertRequest::postInt('submission_id', 0);
$decision = TaskCertRequest::postString('decision', '');
$note = TaskCertRequest::postString('note', '');
}
if ($submissionId <= 0) {
TaskCertResponse::json(['success' => false, 'error' => 'Ungültige submission_id'], 400);
}
if (!in_array($decision, ['approve', 'reject'], true)) {
TaskCertResponse::json(['success' => false, 'error' => 'Ungültige decision'], 400);
}
try {
$services = task_cert_services();
if ($decision === 'approve') {
$services['approvalService']->approveSubmission($submissionId, $auth->contactId(), $note);
} else {
$services['approvalService']->rejectSubmission($submissionId, $auth->contactId(), $note);
}
TaskCertResponse::json([
'success' => true,
'submission_id' => $submissionId,
'decision' => $decision,
]);
} catch (Throwable $throwable) {
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::error('Keine Berechtigung', 403);
}
$term = TaskCertRequest::queryString('term', '');
$limit = TaskCertRequest::queryInt('limit', 15);
$services = task_cert_services();
$lookupRepo = $services['lookupRepo'] ?? null;
if (!$lookupRepo instanceof TaskLookupRepository) {
TaskCertResponse::json(['results' => []]);
}
$contacts = $lookupRepo->searchContacts($term, $limit);
$results = [];
foreach ($contacts as $c) {
$id = (int) ($c['id'] ?? 0);
if ($id <= 0) {
continue;
}
$name = trim((string) ($c['name'] ?? ''));
$email = trim((string) ($c['email'] ?? ''));
$label = $name;
if ($label === '') {
$label = $email !== '' ? $email : 'Kontakt #' . $id;
} elseif ($email !== '') {
$label .= ' (' . $email . ')';
}
$results[] = [
'id' => $id,
'name' => $name,
'email' => $email,
'label' => $label,
];
}
TaskCertResponse::json(['results' => $results]);

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (TaskCertRequest::method() !== 'GET') {
TaskCertResponse::error('Methode nicht erlaubt', 405, false);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->isLoggedIn()) {
TaskCertResponse::error('Nicht eingeloggt', 401, false);
}
$csrfToken = TaskCertRequest::queryString('csrf_token', '');
if (!TaskCertCsrf::validate($csrfToken)) {
TaskCertResponse::error('CSRF validation failed', 419, false);
}
$submissionId = TaskCertRequest::queryInt('submission_id', 0);
$fieldKey = TaskCertRequest::queryString('field_key', '');
$index = TaskCertRequest::queryInt('index', -1);
if ($submissionId <= 0 || $fieldKey === '' || $index < 0) {
TaskCertResponse::error('Ungültige Parameter.', 400, false);
}
try {
$services = task_cert_services();
$submission = $services['submissionRepo']->findById($submissionId);
if ($submission === null) {
TaskCertResponse::error('Einreichung nicht gefunden.', 404, false);
}
// Zugriff: Admin ODER Eigentümer der Einreichung.
$isAdmin = $auth->hasPermission('r-task-cert-admin');
$isOwner = (int) ($submission['main_contact_id'] ?? 0) === $auth->contactId();
if (!$isAdmin && !$isOwner) {
TaskCertResponse::error('Keine Berechtigung', 403, false);
}
$payload = is_array($submission['payload'] ?? null) ? $submission['payload'] : [];
$formValues = is_array($payload['form_values'] ?? null) ? $payload['form_values'] : [];
$fieldFiles = is_array($formValues[$fieldKey] ?? null) ? $formValues[$fieldKey] : [];
$fileMeta = is_array($fieldFiles[$index] ?? null) ? $fieldFiles[$index] : null;
if ($fileMeta === null || !isset($fileMeta['file_path'])) {
TaskCertResponse::error('Datei nicht gefunden.', 404, false);
}
$absolutePath = TaskUploadStorage::absolutePath((string) $fileMeta['file_path']);
if ($absolutePath === null || !is_file($absolutePath)) {
TaskCertResponse::error('Datei nicht gefunden.', 404, false);
}
$fileName = (string) ($fileMeta['file_name'] ?? 'datei');
// Content-Type IMMER aus der (erlaubten) Endung ableiten — nie aus dem
// gespeicherten/Client-MIME (schützt auch Alt-Datensätze vor Stored-XSS,
// z. B. als Bild getarntes SVG).
$mime = TaskUploadPolicy::mimeForFilename($fileName);
// RFC-5987: ASCII-Fallback für alte Clients + UTF-8-URL-encoded für moderne.
$asciiFallback = preg_replace('/[^\x20-\x7E]+/', '_', $fileName) ?: 'datei';
// Inline nur für eine feste, sichere MIME-Menge (kein SVG/HTML/XML),
// alles andere als Download.
$disposition = TaskUploadPolicy::isInlineSafeMime($mime) ? 'inline' : 'attachment';
if (!headers_sent()) {
http_response_code(200);
header('Content-Type: ' . $mime);
header(sprintf(
'Content-Disposition: %s; filename="%s"; filename*=UTF-8\'\'%s',
$disposition,
addcslashes($asciiFallback, '"\\'),
rawurlencode($fileName)
));
header('Content-Length: ' . (int) filesize($absolutePath));
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('Cache-Control: private, max-age=0, must-revalidate');
}
while (ob_get_level() > 0) {
ob_end_clean();
}
readfile($absolutePath);
exit;
} catch (Throwable $throwable) {
TaskCertLogger::error('Submission-Datei-Download fehlgeschlagen', [
'submission_id' => $submissionId,
'field_key' => $fieldKey,
'index' => $index,
'exception' => $throwable,
]);
TaskCertResponse::error('Download fehlgeschlagen.', 500, false);
}

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!function_exists('task_cert_csv_safe_slug')) {
function task_cert_csv_safe_slug(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('/[^a-z0-9_-]+/', '-', $value) ?? '';
$value = trim($value, '-_');
return $value !== '' ? $value : 'export';
}
}
if (TaskCertRequest::method() !== 'GET') {
TaskCertResponse::error('Methode nicht erlaubt', 405, false);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::error('Keine Berechtigung', 403, false);
}
$csrfToken = TaskCertRequest::queryString('csrf_token', '');
if (!TaskCertCsrf::validate($csrfToken)) {
TaskCertResponse::error('CSRF validation failed', 419, false);
}
$taskId = TaskCertRequest::queryInt('id', 0);
if ($taskId <= 0) {
TaskCertResponse::error('Ungültige Aufgaben-ID.', 400, false);
}
$queryMode = TaskCertRequest::queryString('mode', '');
$queryCycleKey = TaskCertRequest::queryString('cycle_key', '');
try {
$services = task_cert_services();
$taskService = $services['taskDefinitionService'] ?? null;
$monitoringService = $services['taskMonitoringService'] ?? null;
if (!$taskService instanceof TaskDefinitionService) {
throw new RuntimeException('TaskDefinitionService konnte nicht geladen werden.');
}
if (!$monitoringService instanceof TaskMonitoringService) {
throw new RuntimeException('TaskMonitoringService konnte nicht geladen werden.');
}
$task = $taskService->getTaskDetail($taskId);
if ($task === null) {
TaskCertResponse::error('Aufgabe nicht gefunden.', 404, false);
}
$export = $monitoringService->buildAnswersExport($task, [
'mode' => $queryMode,
'cycle_key' => $queryCycleKey,
]);
$mode = trim((string) ($export['mode'] ?? 'cycle'));
if (!in_array($mode, ['cycle', 'snapshot'], true)) {
$mode = 'cycle';
}
$selectedCycleKey = trim((string) ($export['cycle_key'] ?? ''));
$rows = is_array($export['rows'] ?? null) ? $export['rows'] : [];
$taskTitle = trim((string) ($task['title'] ?? 'Aufgabe'));
$filenameCyclePart = $mode === 'snapshot'
? 'snapshot'
: ('cycle-' . task_cert_csv_safe_slug($selectedCycleKey !== '' ? $selectedCycleKey : 'none'));
$filename = sprintf(
'task-answers-%d-%s-%s.csv',
$taskId,
$filenameCyclePart,
date('Ymd_His')
);
while (ob_get_level() > 0) {
ob_end_clean();
}
http_response_code(200);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
header('X-Content-Type-Options: nosniff');
$output = fopen('php://output', 'wb');
if (!is_resource($output)) {
throw new RuntimeException('CSV-Ausgabe konnte nicht initialisiert werden.');
}
// UTF-8 BOM for Excel on German locale systems.
fwrite($output, "\xEF\xBB\xBF");
$header = [
'task_id',
'task_title',
'export_mode',
'selected_cycle_key',
'row_cycle_key',
'submission_id',
'assignment_id',
'contact_id',
'contact_name',
'contact_email',
'method_type',
'method_label',
'submission_status',
'assignment_status',
'submitted_at',
'due_at',
'overdue_at',
'completed_at',
'escalation_level',
'score',
'max_score',
'review_note',
'answer_summary',
'answer_detail_json',
];
fputcsv($output, $header, ';');
$toCsvCell = static function ($value): string {
if ($value === null) {
return '';
}
if (is_bool($value)) {
return $value ? '1' : '0';
}
if (is_int($value) || is_float($value)) {
return (string) $value;
}
if (is_array($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($value === false) {
$value = '';
}
}
$stringValue = (string) $value;
$trimmed = ltrim($stringValue);
if ($trimmed !== '') {
$firstChar = substr($trimmed, 0, 1);
if (in_array($firstChar, ['=', '+', '-', '@'], true)) {
$stringValue = "'" . $stringValue;
}
}
return $stringValue;
};
foreach ($rows as $row) {
$row = is_array($row) ? $row : [];
$line = [
$taskId,
$taskTitle,
$mode,
$selectedCycleKey,
(string) ($row['cycle_key'] ?? ''),
(int) ($row['submission_id'] ?? 0),
(int) ($row['assignment_id'] ?? 0),
(int) ($row['contact_id'] ?? 0),
(string) ($row['contact_name'] ?? ''),
(string) ($row['contact_email'] ?? ''),
(string) ($row['method_type'] ?? ''),
(string) ($row['method_label'] ?? ''),
(string) ($row['submission_status'] ?? ''),
(string) ($row['assignment_status'] ?? ''),
(string) ($row['submitted_at'] ?? ''),
(string) ($row['due_at'] ?? ''),
(string) ($row['overdue_at'] ?? ''),
(string) ($row['completed_at'] ?? ''),
(int) ($row['escalation_level'] ?? 0),
$row['score'] !== null ? (int) $row['score'] : '',
$row['max_score'] !== null ? (int) $row['max_score'] : '',
(string) ($row['review_note'] ?? ''),
(string) ($row['answer_summary'] ?? ''),
(string) ($row['answer_detail_json'] ?? '{}'),
];
$csvLine = array_map($toCsvCell, $line);
fputcsv($output, $csvLine, ';');
}
fclose($output);
exit;
} catch (Throwable $throwable) {
$statusCode = $throwable instanceof InvalidArgumentException ? 400 : 500;
TaskCertResponse::error($throwable->getMessage(), $statusCode, false);
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!TaskCertRequest::isPost()) {
TaskCertResponse::json(['success' => false, 'error' => 'Methode nicht erlaubt'], 405);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::json(['success' => false, 'error' => 'Keine Berechtigung'], 403);
}
$contentType = trim((string) ($_SERVER['CONTENT_TYPE'] ?? ''));
$isJson = strpos($contentType, 'application/json') !== false;
if ($isJson) {
$rawBody = file_get_contents('php://input');
$body = json_decode($rawBody ?: '{}', true);
if (!is_array($body)) {
$body = [];
}
$csrfToken = trim((string) ($body['csrf_token'] ?? ''));
if ($csrfToken !== '') {
$_POST['csrf_token'] = $csrfToken;
}
TaskCertCsrf::assertValidFromRequest(true);
$taskId = (int) ($body['task_id'] ?? 0);
$assignmentId = (int) ($body['assignment_id'] ?? 0);
$adminNote = trim((string) ($body['admin_note'] ?? ''));
$methodPayloads = [];
$rawMethods = $body['method'] ?? [];
if (is_array($rawMethods)) {
foreach ($rawMethods as $methodId => $fields) {
$methodId = (int) $methodId;
if ($methodId <= 0 || !is_array($fields)) {
continue;
}
$methodPayloads[$methodId] = $fields;
}
}
} else {
TaskCertCsrf::assertValidFromRequest(true);
$taskId = TaskCertRequest::postInt('task_id', 0);
$assignmentId = TaskCertRequest::postInt('assignment_id', 0);
$adminNote = TaskCertRequest::postString('admin_note', '');
$methodPayloads = [];
$rawMethods = $_POST['method'] ?? [];
if (is_array($rawMethods)) {
foreach ($rawMethods as $methodId => $fields) {
$methodId = (int) $methodId;
if ($methodId <= 0 || !is_array($fields)) {
continue;
}
$methodPayloads[$methodId] = $fields;
}
}
}
if ($taskId <= 0) {
TaskCertResponse::json(['success' => false, 'error' => 'Ungültige task_id'], 400);
}
if ($assignmentId <= 0) {
TaskCertResponse::json(['success' => false, 'error' => 'Ungültige assignment_id'], 400);
}
try {
$services = task_cert_services();
$taskService = $services['taskDefinitionService'] ?? null;
if (!$taskService instanceof TaskDefinitionService) {
throw new RuntimeException('TaskDefinitionService konnte nicht geladen werden.');
}
$stats = $taskService->adminMarkCompleted(
$taskId,
$assignmentId,
$auth->contactId(),
$methodPayloads,
$adminNote
);
TaskCertResponse::json([
'success' => true,
'task_id' => $taskId,
'assignment_id' => $assignmentId,
'stats' => $stats,
]);
} catch (Throwable $throwable) {
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::json(['error' => 'Keine Berechtigung'], 403);
}
$assignmentId = (int) ($_GET['assignment_id'] ?? 0);
if ($assignmentId <= 0) {
TaskCertResponse::json(['error' => 'Ungültige assignment_id'], 400);
}
$services = task_cert_services();
$assignment = $services['assignmentRepo']->findById($assignmentId);
if ($assignment === null) {
TaskCertResponse::json(['error' => 'Zuweisung nicht gefunden'], 404);
}
$taskId = (int) ($assignment['task_id'] ?? 0);
$task = $services['definitionRepo']->findById($taskId);
if ($task === null) {
TaskCertResponse::json(['error' => 'Aufgabe nicht gefunden'], 404);
}
$contactId = (int) ($assignment['main_contact_id'] ?? 0);
$contact = null;
if ($contactId > 0) {
$contact = TaskCertDb::fetchOne(
'SELECT id, name, email FROM main_contact WHERE id = ?',
'i',
[$contactId]
);
}
$methods = array_values(array_filter(
$services['ruleRepo']->getMethodsByTask($taskId),
static fn(array $m): bool => (int) ($m['active'] ?? 1) === 1
));
$existingSubmissions = $services['submissionRepo']->listByAssignment($assignmentId);
$fulfilledMethodIds = [];
foreach ($existingSubmissions as $sub) {
$subStatus = (string) ($sub['status'] ?? '');
if (in_array($subStatus, ['auto_passed', 'approved'], true)) {
$fulfilledMethodIds[(int) ($sub['method_id'] ?? 0)] = true;
}
}
$methodsOut = [];
foreach ($methods as $method) {
$methodId = (int) ($method['id'] ?? 0);
$methodType = (string) ($method['method_type'] ?? '');
$formFields = [];
if ($methodType === 'form_submit') {
foreach ((array) ($method['form_fields'] ?? []) as $field) {
$options = [];
$rawOptions = is_array($field['options'] ?? null) ? $field['options'] : [];
foreach ($rawOptions as $opt) {
$options[] = [
'value' => (string) ($opt['value'] ?? ''),
'label' => (string) ($opt['label'] ?? $opt['value'] ?? ''),
];
}
$formFields[] = [
'field_key' => (string) ($field['field_key'] ?? ''),
'label' => (string) ($field['label'] ?? ''),
'field_type' => (string) ($field['field_type'] ?? 'text'),
'is_required' => (int) ($field['is_required'] ?? 0),
'options' => $options,
'placeholder' => (string) ($field['placeholder'] ?? ''),
];
}
}
$methodsOut[] = [
'id' => $methodId,
'method_type' => $methodType,
'is_required' => (int) ($method['is_required'] ?? 1),
'already_fulfilled' => isset($fulfilledMethodIds[$methodId]),
'knowledge_post_count' => count((array) ($method['knowledge_posts'] ?? [])),
'quiz_question_count' => count((array) ($method['quiz_questions'] ?? [])),
'form_fields' => $formFields,
];
}
TaskCertResponse::json([
'task_id' => $taskId,
'task_title' => (string) ($task['title'] ?? ''),
'assignment_id' => $assignmentId,
'contact_name' => (string) ($contact['name'] ?? '-'),
'contact_email' => (string) ($contact['email'] ?? ''),
'cycle_key' => (string) ($assignment['cycle_key'] ?? ''),
'status' => (string) ($assignment['status'] ?? ''),
'due_at' => (string) ($assignment['due_at'] ?? ''),
'methods' => $methodsOut,
'csrf_token' => TaskCertCsrf::token(),
]);

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
$auth = TaskCertAuthContext::fromGlobals();
$isCli = PHP_SAPI === 'cli';
if (!$isCli && !TaskCertRequest::isPost()) {
TaskCertResponse::error('Methode nicht erlaubt', 405);
}
if (!$isCli) {
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::error('Keine Berechtigung', 403);
}
TaskCertCsrf::assertValidFromRequest(true);
}
try {
$services = task_cert_services();
$count = $services['assignmentResolverService']->materializeAssignmentsForAllActiveTasks();
CronHeartbeat::record('materialize_assignments', ['materialized' => $count]);
TaskCertResponse::json([
'success' => true,
'materialized' => $count,
]);
} catch (Throwable $throwable) {
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
$auth = TaskCertAuthContext::fromGlobals();
$isCli = PHP_SAPI === 'cli';
if (!$isCli && !TaskCertRequest::isPost()) {
TaskCertResponse::error('Methode nicht erlaubt', 405);
}
if (!$isCli) {
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::error('Keine Berechtigung', 403);
}
TaskCertCsrf::assertValidFromRequest(true);
}
try {
$services = task_cert_services();
$apply = true;
$taskId = null;
if ($isCli) {
$options = getopt('', ['task_id::', 'apply', 'dry-run']);
if ($options !== false) {
if (isset($options['task_id']) && !is_array($options['task_id'])) {
$parsedTaskId = (int) $options['task_id'];
if ($parsedTaskId > 0) {
$taskId = $parsedTaskId;
}
}
if (isset($options['dry-run'])) {
$apply = false;
}
if (isset($options['apply'])) {
$apply = true;
}
}
}
$summary = $services['escalationService']->recalculateEscalationsDetailed($apply, $taskId);
$changed = (int) ($summary['changed_assignments'] ?? 0);
if ($apply) {
CronHeartbeat::record('recalculate_escalations', ['changed' => $changed]);
}
TaskCertResponse::json([
'success' => true,
'changed' => $changed,
'summary' => $summary,
]);
} catch (Throwable $throwable) {
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!TaskCertRequest::isPost()) {
TaskCertResponse::json(['success' => false, 'error' => 'Methode nicht erlaubt'], 405);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::json(['success' => false, 'error' => 'Keine Berechtigung'], 403);
}
$contentType = trim((string) ($_SERVER['CONTENT_TYPE'] ?? ''));
$isJson = strpos($contentType, 'application/json') !== false;
if ($isJson) {
$rawBody = file_get_contents('php://input');
$body = json_decode($rawBody ?: '{}', true);
if (!is_array($body)) {
$body = [];
}
$csrfToken = trim((string) ($body['csrf_token'] ?? ''));
if ($csrfToken !== '') {
$_POST['csrf_token'] = $csrfToken;
}
TaskCertCsrf::assertValidFromRequest(true);
$taskId = (int) ($body['task_id'] ?? 0);
$assignmentId = (int) ($body['assignment_id'] ?? 0);
} else {
TaskCertCsrf::assertValidFromRequest(true);
$taskId = TaskCertRequest::postInt('task_id', 0);
$assignmentId = TaskCertRequest::postInt('assignment_id', 0);
}
if ($taskId <= 0) {
TaskCertResponse::json(['success' => false, 'error' => 'Ungültige task_id'], 400);
}
if ($assignmentId <= 0) {
TaskCertResponse::json(['success' => false, 'error' => 'Ungültige assignment_id'], 400);
}
try {
$services = task_cert_services();
$taskService = $services['taskDefinitionService'] ?? null;
if (!$taskService instanceof TaskDefinitionService) {
throw new RuntimeException('TaskDefinitionService konnte nicht geladen werden.');
}
$stats = $taskService->resetAssignmentProgress($taskId, $assignmentId, $auth->contactId());
TaskCertResponse::json([
'success' => true,
'task_id' => $taskId,
'assignment_id' => $assignmentId,
'stats' => $stats,
]);
} catch (Throwable $throwable) {
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!TaskCertRequest::isPost()) {
TaskCertResponse::json(['success' => false, 'error' => 'Methode nicht erlaubt'], 405);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::json(['success' => false, 'error' => 'Keine Berechtigung'], 403);
}
$contentType = trim((string) ($_SERVER['CONTENT_TYPE'] ?? ''));
$isJson = strpos($contentType, 'application/json') !== false;
if ($isJson) {
$rawBody = file_get_contents('php://input');
$body = json_decode($rawBody ?: '{}', true);
if (!is_array($body)) {
$body = [];
}
$csrfToken = trim((string) ($body['csrf_token'] ?? ''));
if ($csrfToken !== '') {
$_POST['csrf_token'] = $csrfToken;
}
TaskCertCsrf::assertValidFromRequest(true);
$taskId = (int) ($body['task_id'] ?? 0);
} else {
TaskCertCsrf::assertValidFromRequest(true);
$taskId = TaskCertRequest::postInt('task_id', 0);
}
if ($taskId <= 0) {
TaskCertResponse::json(['success' => false, 'error' => 'Ungültige task_id'], 400);
}
try {
$services = task_cert_services();
$taskService = $services['taskDefinitionService'] ?? null;
if (!$taskService instanceof TaskDefinitionService) {
throw new RuntimeException('TaskDefinitionService konnte nicht geladen werden.');
}
$stats = $taskService->resetCurrentCycleProgress($taskId, $auth->contactId());
TaskCertResponse::json([
'success' => true,
'task_id' => $taskId,
'stats' => $stats,
]);
} catch (Throwable $throwable) {
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!function_exists('task_cert_sync_assignments_append_query_params')) {
function task_cert_sync_assignments_append_query_params(string $url, array $params): string
{
if ($url === '' || $params === []) {
return $url;
}
$parts = parse_url($url);
if ($parts === false) {
$queryString = http_build_query($params);
if ($queryString === '') {
return $url;
}
$separator = strpos($url, '?') === false ? '?' : '&';
return $url . $separator . $queryString;
}
$path = (string) ($parts['path'] ?? '');
if ($path !== '' && substr($path, -1) !== '/') {
$parts['path'] = $path . '/';
}
$existing = [];
$rawQuery = (string) ($parts['query'] ?? '');
if ($rawQuery !== '') {
parse_str($rawQuery, $existing);
}
foreach ($params as $key => $value) {
if (!is_string($key) || $key === '') {
continue;
}
if ($value === null) {
unset($existing[$key]);
continue;
}
$existing[$key] = $value;
}
$parts['query'] = http_build_query($existing);
return task_cert_sync_assignments_build_url($parts);
}
}
if (!function_exists('task_cert_sync_assignments_build_url')) {
function task_cert_sync_assignments_build_url(array $parts): string
{
$url = '';
if (isset($parts['scheme']) && $parts['scheme'] !== '') {
$url .= $parts['scheme'] . '://';
}
if (isset($parts['user']) && $parts['user'] !== '') {
$url .= $parts['user'];
if (isset($parts['pass']) && $parts['pass'] !== '') {
$url .= ':' . $parts['pass'];
}
$url .= '@';
}
if (isset($parts['host']) && $parts['host'] !== '') {
$url .= $parts['host'];
}
if (isset($parts['port'])) {
$url .= ':' . (int) $parts['port'];
}
$url .= (string) ($parts['path'] ?? '');
$query = (string) ($parts['query'] ?? '');
if ($query !== '') {
$url .= '?' . $query;
}
$fragment = (string) ($parts['fragment'] ?? '');
if ($fragment !== '') {
$url .= '#' . $fragment;
}
return $url;
}
}
if (!function_exists('task_cert_sync_assignments_zero_totals')) {
function task_cert_sync_assignments_zero_totals(bool $applied): array
{
return [
'task_count' => 0,
'target_count' => 0,
'existing_count' => 0,
'create_count' => 0,
'update_count' => 0,
'stale_count' => 0,
'protected_completed_count' => 0,
'protected_certificate_count' => 0,
'remove_count' => 0,
'remove_with_submissions_count' => 0,
'applied' => $applied,
];
}
}
if (!function_exists('task_cert_sync_assignments_sum_totals')) {
function task_cert_sync_assignments_sum_totals(array $results, bool $applied): array
{
$totals = task_cert_sync_assignments_zero_totals($applied);
foreach ($results as $result) {
if (!is_array($result)) {
continue;
}
$totals['task_count']++;
foreach ([
'target_count',
'existing_count',
'create_count',
'update_count',
'stale_count',
'protected_completed_count',
'protected_certificate_count',
'remove_count',
'remove_with_submissions_count',
] as $key) {
$totals[$key] += (int) ($result[$key] ?? 0);
}
}
return $totals;
}
}
$auth = TaskCertAuthContext::fromGlobals();
$isCli = PHP_SAPI === 'cli';
if (!$isCli && !TaskCertRequest::isPost()) {
TaskCertResponse::error('Methode nicht erlaubt', 405);
}
if (!$isCli) {
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::error('Keine Berechtigung', 403);
}
TaskCertCsrf::assertValidFromRequest(true);
}
$taskId = 0;
$mode = 'dry_run';
$scope = 'current_cycle';
$redirectTo = '';
$tabIndex = 0;
if ($isCli) {
$options = getopt('', ['task_id::', 'mode::', 'scope::', 'apply', 'dry-run']);
if ($options !== false) {
if (isset($options['task_id'])) {
$taskId = (int) $options['task_id'];
}
if (isset($options['mode']) && !is_array($options['mode'])) {
$mode = trim((string) $options['mode']);
}
if (isset($options['scope']) && !is_array($options['scope'])) {
$scope = trim((string) $options['scope']);
}
if (isset($options['apply'])) {
$mode = 'apply';
}
if (isset($options['dry-run'])) {
$mode = 'dry_run';
}
}
} else {
$taskId = TaskCertRequest::postInt('task_id', 0);
$mode = TaskCertRequest::postString('mode', 'dry_run');
$scope = TaskCertRequest::postString('scope', 'current_cycle');
$redirectTo = TaskCertRequest::postString('redirect_to', '');
$tabIndex = TaskCertRequest::postInt('tab', 0);
if ($taskId <= 0) {
TaskCertResponse::error('Ungültige task_id', 400);
}
}
if ($tabIndex < 0) {
$tabIndex = 0;
}
if (!in_array($mode, ['dry_run', 'apply'], true)) {
TaskCertResponse::error('Ungültiger mode', 400);
}
if ($scope !== 'current_cycle') {
TaskCertResponse::error('Ungültiger scope', 400);
}
$apply = $mode === 'apply';
try {
$services = task_cert_services();
$resolver = $services['assignmentResolverService'] ?? null;
if (!$resolver instanceof AssignmentResolverService) {
throw new RuntimeException('AssignmentResolverService nicht verfügbar.');
}
$results = [];
$singleResult = null;
if ($taskId > 0) {
$singleResult = $resolver->syncAssignmentsForTask($taskId, $apply, $scope);
$results[] = $singleResult;
} else {
$results = $resolver->syncAssignmentsForAllActiveTasks($apply, $scope);
}
$totals = task_cert_sync_assignments_sum_totals($results, $apply);
if ($apply && $taskId <= 0) {
CronHeartbeat::record('sync_assignments', [
'task_count' => (int) ($totals['task_count'] ?? 0),
'create_count' => (int) ($totals['create_count'] ?? 0),
'remove_count' => (int) ($totals['remove_count'] ?? 0),
]);
}
if (!$isCli && $redirectTo !== '' && $singleResult !== null) {
$redirectTo = task_cert_sync_assignments_append_query_params($redirectTo, [
'sync_done' => 1,
'sync_mode' => $mode,
'sync_applied' => $apply ? 1 : 0,
'sync_scope' => $scope,
'tab' => $tabIndex,
'sync_cycle_key' => (string) ($singleResult['cycle_key'] ?? ''),
'sync_target_count' => (int) ($singleResult['target_count'] ?? 0),
'sync_existing_count' => (int) ($singleResult['existing_count'] ?? 0),
'sync_create_count' => (int) ($singleResult['create_count'] ?? 0),
'sync_update_count' => (int) ($singleResult['update_count'] ?? 0),
'sync_stale_count' => (int) ($singleResult['stale_count'] ?? 0),
'sync_protected_completed_count' => (int) ($singleResult['protected_completed_count'] ?? 0),
'sync_protected_certificate_count' => (int) ($singleResult['protected_certificate_count'] ?? 0),
'sync_remove_count' => (int) ($singleResult['remove_count'] ?? 0),
'sync_remove_with_submissions_count' => (int) ($singleResult['remove_with_submissions_count'] ?? 0),
]);
TaskCertResponse::redirect($redirectTo);
}
TaskCertResponse::json([
'success' => true,
'mode' => $mode,
'scope' => $scope,
'task_id' => $taskId > 0 ? $taskId : null,
'result' => $singleResult,
'results' => $taskId > 0 ? [] : $results,
'totals' => $totals,
]);
} catch (Throwable $throwable) {
if (!$isCli && $redirectTo !== '') {
$redirectTo = task_cert_sync_assignments_append_query_params($redirectTo, [
'sync_done' => 0,
'tab' => $tabIndex,
'sync_error' => $throwable->getMessage(),
]);
TaskCertResponse::redirect($redirectTo);
}
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!function_exists('task_cert_category_delete_append_query_params')) {
function task_cert_category_delete_append_query_params(string $url, array $params): string
{
if ($url === '' || $params === []) {
return $url;
}
$parts = parse_url($url);
if ($parts === false) {
$queryString = http_build_query($params);
if ($queryString === '') {
return $url;
}
$separator = strpos($url, '?') === false ? '?' : '&';
return $url . $separator . $queryString;
}
$path = (string) ($parts['path'] ?? '');
if ($path !== '' && substr($path, -1) !== '/') {
$parts['path'] = $path . '/';
}
$existing = [];
$rawQuery = (string) ($parts['query'] ?? '');
if ($rawQuery !== '') {
parse_str($rawQuery, $existing);
}
foreach ($params as $key => $value) {
if (!is_string($key) || $key === '') {
continue;
}
if ($value === null) {
unset($existing[$key]);
continue;
}
$existing[$key] = $value;
}
$parts['query'] = http_build_query($existing);
return task_cert_category_delete_build_url($parts);
}
}
if (!function_exists('task_cert_category_delete_build_url')) {
function task_cert_category_delete_build_url(array $parts): string
{
$url = '';
if (isset($parts['scheme']) && $parts['scheme'] !== '') {
$url .= $parts['scheme'] . '://';
}
if (isset($parts['user']) && $parts['user'] !== '') {
$url .= $parts['user'];
if (isset($parts['pass']) && $parts['pass'] !== '') {
$url .= ':' . $parts['pass'];
}
$url .= '@';
}
if (isset($parts['host']) && $parts['host'] !== '') {
$url .= $parts['host'];
}
if (isset($parts['port'])) {
$url .= ':' . (int) $parts['port'];
}
$url .= (string) ($parts['path'] ?? '');
$query = (string) ($parts['query'] ?? '');
if ($query !== '') {
$url .= '?' . $query;
}
$fragment = (string) ($parts['fragment'] ?? '');
if ($fragment !== '') {
$url .= '#' . $fragment;
}
return $url;
}
}
if (!TaskCertRequest::isPost()) {
TaskCertResponse::error('Methode nicht erlaubt', 405);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::error('Keine Berechtigung', 403);
}
TaskCertCsrf::assertValidFromRequest(true);
$categoryId = TaskCertRequest::postInt('category_id', 0);
$redirectTo = TaskCertRequest::postString('redirect_to', '');
if ($categoryId <= 0) {
TaskCertResponse::error('Ungültige category_id', 400);
}
try {
$services = task_cert_services();
$categoryService = $services['taskCategoryService'] ?? null;
if (!$categoryService instanceof TaskCategoryService) {
throw new RuntimeException('Kategorie-Service nicht verfügbar.');
}
$result = $categoryService->deleteCategory($categoryId, $auth->contactId());
if ($redirectTo !== '') {
$redirectTo = task_cert_category_delete_append_query_params($redirectTo, [
'cat_done' => 1,
'cat_action' => 'category_delete',
'cat_error' => null,
]);
TaskCertResponse::redirect($redirectTo);
}
TaskCertResponse::json([
'success' => true,
'action' => 'category_delete',
'result' => $result,
]);
} catch (Throwable $throwable) {
if ($redirectTo !== '') {
$redirectTo = task_cert_category_delete_append_query_params($redirectTo, [
'cat_done' => 0,
'cat_error' => $throwable->getMessage(),
]);
TaskCertResponse::redirect($redirectTo);
}
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!function_exists('task_cert_category_active_append_query_params')) {
function task_cert_category_active_append_query_params(string $url, array $params): string
{
if ($url === '' || $params === []) {
return $url;
}
$parts = parse_url($url);
if ($parts === false) {
$queryString = http_build_query($params);
if ($queryString === '') {
return $url;
}
$separator = strpos($url, '?') === false ? '?' : '&';
return $url . $separator . $queryString;
}
$path = (string) ($parts['path'] ?? '');
if ($path !== '' && substr($path, -1) !== '/') {
$parts['path'] = $path . '/';
}
$existing = [];
$rawQuery = (string) ($parts['query'] ?? '');
if ($rawQuery !== '') {
parse_str($rawQuery, $existing);
}
foreach ($params as $key => $value) {
if (!is_string($key) || $key === '') {
continue;
}
if ($value === null) {
unset($existing[$key]);
continue;
}
$existing[$key] = $value;
}
$parts['query'] = http_build_query($existing);
return task_cert_category_active_build_url($parts);
}
}
if (!function_exists('task_cert_category_active_build_url')) {
function task_cert_category_active_build_url(array $parts): string
{
$url = '';
if (isset($parts['scheme']) && $parts['scheme'] !== '') {
$url .= $parts['scheme'] . '://';
}
if (isset($parts['user']) && $parts['user'] !== '') {
$url .= $parts['user'];
if (isset($parts['pass']) && $parts['pass'] !== '') {
$url .= ':' . $parts['pass'];
}
$url .= '@';
}
if (isset($parts['host']) && $parts['host'] !== '') {
$url .= $parts['host'];
}
if (isset($parts['port'])) {
$url .= ':' . (int) $parts['port'];
}
$url .= (string) ($parts['path'] ?? '');
$query = (string) ($parts['query'] ?? '');
if ($query !== '') {
$url .= '?' . $query;
}
$fragment = (string) ($parts['fragment'] ?? '');
if ($fragment !== '') {
$url .= '#' . $fragment;
}
return $url;
}
}
if (!TaskCertRequest::isPost()) {
TaskCertResponse::error('Methode nicht erlaubt', 405);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::error('Keine Berechtigung', 403);
}
TaskCertCsrf::assertValidFromRequest(true);
$categoryId = TaskCertRequest::postInt('category_id', 0);
$active = TaskCertRequest::postInt('active', -1);
$redirectTo = TaskCertRequest::postString('redirect_to', '');
if ($categoryId <= 0) {
TaskCertResponse::error('Ungültige category_id', 400);
}
if (!in_array($active, [0, 1], true)) {
TaskCertResponse::error('Ungültiger active-Wert', 400);
}
try {
$services = task_cert_services();
$categoryService = $services['taskCategoryService'] ?? null;
if (!$categoryService instanceof TaskCategoryService) {
throw new RuntimeException('Kategorie-Service nicht verfügbar.');
}
$result = $categoryService->setActive($categoryId, $active, $auth->contactId());
$action = (string) ($result['action'] ?? ($active === 1 ? 'category_activate' : 'category_deactivate'));
if ($redirectTo !== '') {
$redirectTo = task_cert_category_active_append_query_params($redirectTo, [
'cat_done' => 1,
'cat_action' => $action,
'cat_error' => null,
]);
TaskCertResponse::redirect($redirectTo);
}
TaskCertResponse::json([
'success' => true,
'action' => $action,
'category' => $result['category'] ?? null,
]);
} catch (Throwable $throwable) {
if ($redirectTo !== '') {
$redirectTo = task_cert_category_active_append_query_params($redirectTo, [
'cat_done' => 0,
'cat_error' => $throwable->getMessage(),
]);
TaskCertResponse::redirect($redirectTo);
}
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!function_exists('task_cert_category_upsert_append_query_params')) {
function task_cert_category_upsert_append_query_params(string $url, array $params): string
{
if ($url === '' || $params === []) {
return $url;
}
$parts = parse_url($url);
if ($parts === false) {
$queryString = http_build_query($params);
if ($queryString === '') {
return $url;
}
$separator = strpos($url, '?') === false ? '?' : '&';
return $url . $separator . $queryString;
}
$path = (string) ($parts['path'] ?? '');
if ($path !== '' && substr($path, -1) !== '/') {
$parts['path'] = $path . '/';
}
$existing = [];
$rawQuery = (string) ($parts['query'] ?? '');
if ($rawQuery !== '') {
parse_str($rawQuery, $existing);
}
foreach ($params as $key => $value) {
if (!is_string($key) || $key === '') {
continue;
}
if ($value === null) {
unset($existing[$key]);
continue;
}
$existing[$key] = $value;
}
$parts['query'] = http_build_query($existing);
return task_cert_category_upsert_build_url($parts);
}
}
if (!function_exists('task_cert_category_upsert_build_url')) {
function task_cert_category_upsert_build_url(array $parts): string
{
$url = '';
if (isset($parts['scheme']) && $parts['scheme'] !== '') {
$url .= $parts['scheme'] . '://';
}
if (isset($parts['user']) && $parts['user'] !== '') {
$url .= $parts['user'];
if (isset($parts['pass']) && $parts['pass'] !== '') {
$url .= ':' . $parts['pass'];
}
$url .= '@';
}
if (isset($parts['host']) && $parts['host'] !== '') {
$url .= $parts['host'];
}
if (isset($parts['port'])) {
$url .= ':' . (int) $parts['port'];
}
$url .= (string) ($parts['path'] ?? '');
$query = (string) ($parts['query'] ?? '');
if ($query !== '') {
$url .= '?' . $query;
}
$fragment = (string) ($parts['fragment'] ?? '');
if ($fragment !== '') {
$url .= '#' . $fragment;
}
return $url;
}
}
if (!TaskCertRequest::isPost()) {
TaskCertResponse::error('Methode nicht erlaubt', 405);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::error('Keine Berechtigung', 403);
}
TaskCertCsrf::assertValidFromRequest(true);
$categoryId = TaskCertRequest::postInt('category_id', 0);
$name = TaskCertRequest::postString('name', '');
$redirectTo = TaskCertRequest::postString('redirect_to', '');
$activeRaw = TaskCertRequest::postString('active', '');
$activeOverride = null;
if ($activeRaw !== '') {
$activeValue = (int) $activeRaw;
if (!in_array($activeValue, [0, 1], true)) {
TaskCertResponse::error('Ungültiger Statuswert', 400);
}
$activeOverride = $activeValue;
}
try {
$services = task_cert_services();
$categoryService = $services['taskCategoryService'] ?? null;
if (!$categoryService instanceof TaskCategoryService) {
throw new RuntimeException('Kategorie-Service nicht verfügbar.');
}
$result = $categoryService->upsert($categoryId, $name, $auth->contactId(), $activeOverride);
$action = (string) ($result['action'] ?? ($categoryId > 0 ? 'category_update' : 'category_create'));
if ($redirectTo !== '') {
$redirectTo = task_cert_category_upsert_append_query_params($redirectTo, [
'cat_done' => 1,
'cat_action' => $action,
'cat_error' => null,
]);
TaskCertResponse::redirect($redirectTo);
}
TaskCertResponse::json([
'success' => true,
'category' => $result['category'] ?? null,
'action' => $action,
]);
} catch (Throwable $throwable) {
if ($redirectTo !== '') {
$redirectTo = task_cert_category_upsert_append_query_params($redirectTo, [
'cat_done' => 0,
'cat_error' => $throwable->getMessage(),
]);
TaskCertResponse::redirect($redirectTo);
}
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!TaskCertRequest::isPost()) {
TaskCertResponse::error('Methode nicht erlaubt', 405);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::error('Keine Berechtigung', 403);
}
TaskCertCsrf::assertValidFromRequest(true);
$taskId = TaskCertRequest::postInt('task_id', 0);
if ($taskId <= 0) {
TaskCertResponse::error('Ungültige task_id', 400);
}
try {
$services = task_cert_services();
$services['taskDefinitionService']->softDeleteTask($taskId);
$redirectTo = TaskCertRequest::postString('redirect_to', '');
if ($redirectTo !== '') {
TaskCertResponse::redirect($redirectTo);
}
TaskCertResponse::json([
'success' => true,
'task_id' => $taskId,
]);
} catch (Throwable $throwable) {
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!TaskCertRequest::isPost()) {
TaskCertResponse::error('Methode nicht erlaubt', 405);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::error('Keine Berechtigung', 403);
}
TaskCertCsrf::assertValidFromRequest(true);
$taskId = TaskCertRequest::postInt('task_id', 0);
if ($taskId <= 0) {
TaskCertResponse::error('Ungültige task_id', 400);
}
$confirmed = TaskCertRequest::postInt('confirm_hard_delete', 0) === 1;
if (!$confirmed) {
TaskCertResponse::error('Bitte endgültiges Löschen bestätigen.', 400);
}
try {
$services = task_cert_services();
$impact = $services['taskDefinitionService']->hardDeleteTask($taskId);
$redirectTo = TaskCertRequest::postString('redirect_to', '');
if ($redirectTo !== '') {
TaskCertResponse::redirect($redirectTo);
}
TaskCertResponse::json([
'success' => true,
'task_id' => $taskId,
'deleted' => [
'task' => 1,
'methods' => (int) ($impact['methods'] ?? 0),
'assignments' => (int) ($impact['assignments'] ?? 0),
'submissions' => (int) ($impact['submissions'] ?? 0),
'certificates' => (int) ($impact['certificates'] ?? 0),
'escalation_events' => (int) ($impact['escalation_events'] ?? 0),
],
]);
} catch (Throwable $throwable) {
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,429 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!function_exists('task_cert_append_query_params')) {
function task_cert_append_query_params(string $url, array $params): string
{
if ($url === '' || $params === []) {
return $url;
}
$queryString = http_build_query($params);
if ($queryString === '') {
return $url;
}
$separator = strpos($url, '?') === false ? '?' : '&';
return $url . $separator . $queryString;
}
}
if (!TaskCertRequest::isPost()) {
TaskCertResponse::error('Methode nicht erlaubt', 405);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->hasPermission('r-task-cert-admin')) {
TaskCertResponse::error('Keine Berechtigung', 403);
}
TaskCertCsrf::assertValidFromRequest(true);
$services = task_cert_services();
$taskService = $services['taskDefinitionService'];
$parseCsvOptions = static function (string $raw): array {
$result = [];
$parts = preg_split('/[,\n]/', $raw) ?: [];
foreach ($parts as $part) {
$item = trim((string) $part);
if ($item === '') {
continue;
}
$result[] = [
'value' => $item,
'label' => $item,
];
}
return $result;
};
$slugifyFieldKey = static function (string $label): string {
$map = [
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss',
'Ä' => 'ae', 'Ö' => 'oe', 'Ü' => 'ue',
];
$slug = strtr(mb_strtolower($label, 'UTF-8'), $map);
$slug = preg_replace('/[^a-z0-9]+/', '_', $slug) ?? '';
return trim($slug, '_');
};
$buildMethods = static function () use ($parseCsvOptions, $slugifyFieldKey): array {
$methodEnabled = TaskCertRequest::postArray('method_enabled');
$orderedTypes = TaskCertConstants::METHOD_TYPES;
$methods = [];
$sortOrder = 0;
foreach ($orderedTypes as $methodType) {
if ((int) ($methodEnabled[$methodType] ?? 0) !== 1) {
continue;
}
$method = [
'method_type' => $methodType,
'is_required' => 1,
'sort_order' => $sortOrder,
'active' => 1,
'settings' => [],
];
if ($methodType === 'knowledge_read') {
$knowledgePosts = [];
$postIds = TaskCertRequest::postIntArray('knowledge_post_ids');
foreach ($postIds as $index => $postId) {
$knowledgePosts[] = [
'knowledge_post_id' => $postId,
'required' => 1,
'sort_order' => $index,
];
}
$method['knowledge_posts'] = $knowledgePosts;
}
if ($methodType === 'form_submit') {
$formFields = [];
$rows = TaskCertRequest::postArray('form_fields');
foreach ($rows as $idx => $row) {
if (!is_array($row)) {
continue;
}
$label = trim((string) ($row['label'] ?? ''));
if ($label === '') {
continue;
}
$fieldKey = trim((string) ($row['field_key'] ?? ''));
if ($fieldKey === '') {
$base = $slugifyFieldKey($label);
if ($base === '') {
$base = 'field';
}
$fieldKey = $base;
$usedKeys = array_column($formFields, 'field_key');
$suffix = 2;
while (in_array($fieldKey, $usedKeys, true)) {
$fieldKey = $base . '_' . $suffix;
$suffix++;
}
}
$fieldType = (string) ($row['field_type'] ?? 'text');
if (!TaskCertConstants::isValidFieldType($fieldType)) {
$fieldType = 'text';
}
$formFields[] = [
'field_key' => $fieldKey,
'label' => $label,
'field_type' => $fieldType,
'is_required' => (int) (($row['is_required'] ?? 0) ? 1 : 0),
'options' => $parseCsvOptions((string) ($row['options_text'] ?? '')),
'placeholder' => trim((string) ($row['placeholder'] ?? '')),
'prefill_column' => ContactPrefillColumns::sanitize(trim((string) ($row['prefill_column'] ?? ''))),
'sort_order' => count($formFields),
'active' => 1,
];
}
$method['form_fields'] = $formFields;
}
if ($methodType === 'quiz') {
$quizQuestions = [];
$rows = TaskCertRequest::postArray('quiz_questions');
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$questionText = trim((string) ($row['question_text'] ?? ''));
if ($questionText === '') {
continue;
}
$correctOption = strtolower(trim((string) ($row['correct_option'] ?? 'a')));
if (!in_array($correctOption, ['a', 'b', 'c', 'd'], true)) {
$correctOption = 'a';
}
$optionsMap = [
'a' => trim((string) ($row['option_a'] ?? '')),
'b' => trim((string) ($row['option_b'] ?? '')),
'c' => trim((string) ($row['option_c'] ?? '')),
'd' => trim((string) ($row['option_d'] ?? '')),
];
$options = [];
foreach (['a', 'b', 'c', 'd'] as $position => $letter) {
$optionText = $optionsMap[$letter];
if ($optionText === '') {
continue;
}
$options[] = [
'option_text' => $optionText,
'is_correct' => $letter === $correctOption ? 1 : 0,
'sort_order' => $position,
];
}
if ($options === []) {
continue;
}
$quizQuestions[] = [
'question_text' => $questionText,
'sort_order' => count($quizQuestions),
'active' => 1,
'options' => $options,
];
}
$method['quiz_questions'] = $quizQuestions;
}
$methods[] = $method;
$sortOrder++;
}
return $methods;
};
$buildTargetRules = static function (): array {
$mandantIds = array_values(array_unique(TaskCertRequest::postIntArray('target_mandant_ids')));
$departmentIds = array_values(array_unique(TaskCertRequest::postIntArray('target_department_ids')));
$roleIds = array_values(array_unique(TaskCertRequest::postIntArray('target_role_ids')));
$einrichtIds = array_values(array_unique(TaskCertRequest::postIntArray('target_einricht_ids')));
$fachbereichIds = array_values(array_unique(TaskCertRequest::postIntArray('target_fachbereich_ids')));
$mandantValues = $mandantIds === [] ? [0] : $mandantIds;
$departmentValues = $departmentIds === [] ? [0] : $departmentIds;
$roleValues = $roleIds === [] ? [0] : $roleIds;
$einrichtValues = $einrichtIds === [] ? [0] : $einrichtIds;
$fachbereichValues = $fachbereichIds === [] ? [0] : $fachbereichIds;
$rules = [];
$count = 0;
$maxRules = 5000;
foreach ($mandantValues as $mandantId) {
foreach ($departmentValues as $departmentId) {
foreach ($roleValues as $roleId) {
foreach ($einrichtValues as $einrichtId) {
foreach ($fachbereichValues as $fachbereichId) {
if ((int) $mandantId === 0 && (int) $departmentId === 0 && (int) $roleId === 0
&& (int) $einrichtId === 0 && (int) $fachbereichId === 0) {
continue;
}
$rules[] = [
'main_mandant_id' => (int) $mandantId ?: null,
'main_department_id' => (int) $departmentId ?: null,
'main_role_id' => (int) $roleId ?: null,
'main_einricht_id' => (int) $einrichtId ?: null,
'main_bereich_id' => (int) $fachbereichId ?: null,
'active' => 1,
];
$count++;
if ($count > $maxRules) {
throw new RuntimeException('Zu viele Regelkombinationen. Bitte Auswahl einschränken.');
}
}
}
}
}
}
return $rules;
};
$buildTargetOverrides = static function (): array {
$includeReason = TaskCertRequest::postString('override_include_reason', '');
$excludeReason = TaskCertRequest::postString('override_exclude_reason', '');
$includeIds = array_values(array_unique(TaskCertRequest::postIntArray('override_include_contact_ids')));
$excludeIds = array_values(array_unique(TaskCertRequest::postIntArray('override_exclude_contact_ids')));
// exclude wins when the same contact is selected in both lists
$includeIds = array_values(array_diff($includeIds, $excludeIds));
$overrides = [];
foreach ($includeIds as $contactId) {
$overrides[] = [
'main_contact_id' => $contactId,
'override_type' => 'include',
'reason' => $includeReason,
];
}
foreach ($excludeIds as $contactId) {
$overrides[] = [
'main_contact_id' => $contactId,
'override_type' => 'exclude',
'reason' => $excludeReason,
];
}
return $overrides;
};
try {
$methods = $buildMethods();
if ($methods === []) {
throw new RuntimeException('Mindestens eine Erledigungsart muss aktiviert sein.');
}
$deadlineRuleType = TaskCertRequest::postString('deadline_rule_type', 'cycle_end');
if (!in_array($deadlineRuleType, ['cycle_end', 'offset_days', 'weekday', 'monthday'], true)) {
$deadlineRuleType = 'cycle_end';
}
$deadlineOffsetDays = null;
$deadlineWeekday = null;
$deadlineMonthday = null;
if ($deadlineRuleType === 'offset_days') {
$deadlineOffsetDays = TaskCertRequest::postInt('deadline_offset_days', 0);
if ($deadlineOffsetDays <= 0) {
throw new RuntimeException('Bitte Frist in Tagen größer 0 angeben.');
}
}
if ($deadlineRuleType === 'weekday') {
$deadlineWeekday = TaskCertRequest::postInt('deadline_weekday', 0);
if ($deadlineWeekday < 1 || $deadlineWeekday > 7) {
throw new RuntimeException('Bitte einen gültigen Wochentag (1-7) auswählen.');
}
}
if ($deadlineRuleType === 'monthday') {
$deadlineMonthday = TaskCertRequest::postInt('deadline_monthday', 0);
if ($deadlineMonthday < 1 || $deadlineMonthday > 31) {
throw new RuntimeException('Bitte einen gültigen Monatstag (1-31) auswählen.');
}
}
$payload = [
'actor_id' => $auth->contactId(),
'title' => TaskCertRequest::postString('title'),
'description' => TaskCertHtml::sanitize(TaskCertRequest::postString('description')),
'task_category_id' => TaskCertRequest::postInt('task_category_id', 0),
'frequency_type' => TaskCertRequest::postString('frequency_type', 'once'),
'recurrence_basis' => TaskCertRequest::postString('recurrence_basis', 'calendar'),
'recurrence_unit' => TaskCertRequest::postString('recurrence_unit', ''),
'recurrence_interval' => TaskCertRequest::postInt('recurrence_interval', 1),
'start_date' => TaskCertRequest::postString('start_date', date('Y-m-d')),
'deadline_rule_type' => $deadlineRuleType,
'deadline_offset_days' => $deadlineOffsetDays,
'deadline_weekday' => $deadlineWeekday,
'deadline_monthday' => $deadlineMonthday,
'fulfillment_mode' => TaskCertRequest::postString('fulfillment_mode', 'all'),
'certificate_enabled' => TaskCertRequest::postInt('certificate_enabled', 1),
'certificate_title' => TaskCertRequest::postString('certificate_title', ''),
'certificate_validity_mode' => TaskCertRequest::postString('certificate_validity_mode', ''),
'certificate_validity_days' => TaskCertRequest::postString('certificate_validity_days', ''),
'show_cycle_period' => TaskCertRequest::postInt('show_cycle_period', 0) === 1 ? 1 : 0,
'active' => TaskCertRequest::postInt('active', 1),
'methods' => $methods,
'target_rules' => $buildTargetRules(),
'target_overrides' => $buildTargetOverrides(),
'responsible_contact_ids' => array_values(array_unique(TaskCertRequest::postIntArray('responsible_contact_ids'))),
'reminder_policy' => [
'base_mode' => 'due_date',
'first_escalation_days' => TaskCertRequest::postInt('reminder_first_escalation_days', 0),
'step_days' => TaskCertRequest::postInt('reminder_step_days', 7),
'max_level' => TaskCertRequest::postInt('reminder_max_level', 3),
'grace_days' => TaskCertRequest::postInt('reminder_grace_days', 7),
'active' => TaskCertRequest::postInt('reminder_active', 0) === 1 ? 1 : 0,
],
];
$taskId = TaskCertRequest::postInt('task_id', 0);
$resetExistingRecords = $taskId > 0 && TaskCertRequest::postInt('reset_existing_records', 0) === 1;
$resetStats = [
'cycle_key' => '',
'assignment_count' => 0,
'submission_count' => 0,
'certificate_count' => 0,
];
if ($taskId > 0) {
$taskService->updateTask($taskId, $payload);
if ($resetExistingRecords) {
$resetStats = $taskService->resetCurrentCycleProgress($taskId, $auth->contactId());
}
} else {
$taskId = $taskService->createTask($payload);
}
$afterSave = TaskCertRequest::postString('after_save', 'stay');
if (!in_array($afterSave, ['stay', 'close'], true)) {
$afterSave = 'stay';
}
$redirectTo = TaskCertRequest::postString('redirect_to', '');
$redirectToStay = TaskCertRequest::postString('redirect_to_stay', '');
$redirectToClose = TaskCertRequest::postString('redirect_to_close', '');
if ($redirectToStay === '') {
$redirectToStay = task_cert_view_url('admin.task_form', [
'id' => $taskId,
'saved' => 1,
]);
}
if ($redirectToClose === '') {
$redirectToClose = task_cert_view_url('admin.task_list');
}
$resolvedRedirect = $redirectTo;
if ($resolvedRedirect === '') {
$resolvedRedirect = $afterSave === 'close' ? $redirectToClose : $redirectToStay;
}
if ($resolvedRedirect !== '') {
$resolvedRedirect = str_replace('{task_id}', (string) $taskId, $resolvedRedirect);
if ($resetExistingRecords) {
$resolvedRedirect = task_cert_append_query_params($resolvedRedirect, [
'reset' => 1,
'reset_assignments' => (int) ($resetStats['assignment_count'] ?? 0),
'reset_submissions' => (int) ($resetStats['submission_count'] ?? 0),
'reset_certificates' => (int) ($resetStats['certificate_count'] ?? 0),
]);
}
TaskCertResponse::redirect($resolvedRedirect);
}
TaskCertResponse::json([
'success' => true,
'task_id' => $taskId,
'redirect' => $resolvedRedirect,
'reset' => $resetExistingRecords,
'reset_stats' => $resetStats,
]);
} catch (Throwable $throwable) {
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!TaskCertRequest::isPost()) {
TaskCertResponse::error('Methode nicht erlaubt', 405);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->isLoggedIn()) {
TaskCertResponse::error('Nicht eingeloggt', 401);
}
TaskCertCsrf::assertValidFromRequest(true);
$assignmentId = TaskCertRequest::postInt('assignment_id', 0);
$methodId = TaskCertRequest::postInt('method_id', 0);
$note = TaskCertRequest::postString('note', '');
if ($assignmentId <= 0 || $methodId <= 0) {
TaskCertResponse::error('Ungültige assignment_id oder method_id', 400);
}
try {
$services = task_cert_services();
$submissionId = $services['completionService']->requestManualApproval($assignmentId, $methodId, $note);
$redirectTo = TaskCertRequest::postString('redirect_to', '');
if ($redirectTo !== '') {
TaskCertResponse::redirect($redirectTo);
}
TaskCertResponse::json([
'success' => true,
'submission_id' => $submissionId,
]);
} catch (Throwable $throwable) {
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!TaskCertRequest::isPost()) {
TaskCertResponse::error('Methode nicht erlaubt', 405);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->isLoggedIn()) {
TaskCertResponse::error('Nicht eingeloggt', 401);
}
TaskCertCsrf::assertValidFromRequest(true);
$assignmentId = TaskCertRequest::postInt('assignment_id', 0);
$methodId = TaskCertRequest::postInt('method_id', 0);
$values = TaskCertRequest::postArray('values');
$files = [];
if (isset($_FILES['files']) && is_array($_FILES['files'])) {
$files = TaskUploadStorage::normalizeFilesInput($_FILES['files']);
}
if ($assignmentId <= 0 || $methodId <= 0) {
TaskCertResponse::error('Ungültige assignment_id oder method_id', 400);
}
try {
$services = task_cert_services();
$submissionId = $services['completionService']->submitForm($assignmentId, $methodId, $values, $files);
$redirectTo = TaskCertRequest::postString('redirect_to', '');
if ($redirectTo !== '') {
TaskCertResponse::redirect($redirectTo);
}
TaskCertResponse::json([
'success' => true,
'submission_id' => $submissionId,
]);
} catch (Throwable $throwable) {
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!TaskCertRequest::isPost()) {
TaskCertResponse::error('Methode nicht erlaubt', 405);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->isLoggedIn()) {
TaskCertResponse::error('Nicht eingeloggt', 401);
}
TaskCertCsrf::assertValidFromRequest(true);
$assignmentId = TaskCertRequest::postInt('assignment_id', 0);
$methodId = TaskCertRequest::postInt('method_id', 0);
$answersRaw = TaskCertRequest::postArray('answers');
if ($assignmentId <= 0 || $methodId <= 0) {
TaskCertResponse::error('Ungültige assignment_id oder method_id', 400);
}
$answers = [];
foreach ($answersRaw as $questionId => $questionAnswers) {
$questionIdInt = (int) $questionId;
if ($questionIdInt <= 0) {
continue;
}
if (!is_array($questionAnswers)) {
$questionAnswers = [$questionAnswers];
}
$answers[$questionIdInt] = array_values(array_unique(array_map('intval', $questionAnswers)));
}
try {
$services = task_cert_services();
$submissionId = $services['completionService']->submitQuiz($assignmentId, $methodId, $answers);
$redirectTo = TaskCertRequest::postString('redirect_to', '');
if ($redirectTo !== '') {
TaskCertResponse::redirect($redirectTo);
}
TaskCertResponse::json([
'success' => true,
'submission_id' => $submissionId,
]);
} catch (Throwable $throwable) {
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
if (!function_exists('task_cert_submit_read_redirect_with_message')) {
function task_cert_submit_read_redirect_with_message(string $redirectTo, string $queryKey, string $message): void
{
if ($redirectTo === '') {
return;
}
$separator = (strpos($redirectTo, '?') === false) ? '?' : '&';
TaskCertResponse::redirect($redirectTo . $separator . $queryKey . '=' . rawurlencode($message));
}
}
if (!TaskCertRequest::isPost()) {
TaskCertResponse::error('Methode nicht erlaubt', 405);
}
$auth = TaskCertAuthContext::fromGlobals();
if (!$auth->isLoggedIn()) {
TaskCertResponse::error('Nicht eingeloggt', 401);
}
TaskCertCsrf::assertValidFromRequest(true);
$assignmentId = TaskCertRequest::postInt('assignment_id', 0);
$methodId = TaskCertRequest::postInt('method_id', 0);
$postIds = TaskCertRequest::postIntArray('post_ids');
$postReadState = TaskCertRequest::postArray('post_read_state');
$redirectTo = TaskCertRequest::postString('redirect_to', '');
if ($assignmentId <= 0 || $methodId <= 0) {
TaskCertResponse::error('Ungültige assignment_id oder method_id', 400);
}
if ($postReadState !== []) {
foreach ($postReadState as $postIdRaw => $stateRaw) {
$postId = (int) $postIdRaw;
if ($postId <= 0) {
continue;
}
$state = strtolower(trim((string) $stateRaw));
if (in_array($state, ['read', '1', 'yes', 'true'], true)) {
$postIds[] = $postId;
}
}
$postIds = array_values(array_unique(array_map('intval', $postIds)));
$postIds = array_values(array_filter($postIds, static function (int $postId): bool {
return $postId > 0;
}));
}
try {
$services = task_cert_services();
$submissionId = $services['completionService']->submitRead($assignmentId, $methodId, $postIds);
$submission = $services['submissionRepo']->findById($submissionId);
$submissionStatus = (string) ($submission['status'] ?? '');
$payload = is_array($submission['payload'] ?? null) ? $submission['payload'] : [];
if ($redirectTo !== '') {
if ($submissionStatus === 'submitted') {
$missingCount = is_array($payload['missing_post_ids'] ?? null) ? count($payload['missing_post_ids']) : 0;
$missingNoun = $missingCount === 1 ? 'Pflichtbeitrag' : 'Pflichtbeiträge';
$message = $missingCount > 0
? ('Zwischengespeichert. Bitte noch ' . $missingCount . ' ' . $missingNoun . ' als gelesen markieren.')
: 'Zwischengespeichert. Bitte alle Pflichtbeiträge als gelesen markieren.';
task_cert_submit_read_redirect_with_message($redirectTo, 'task_cert_warning', $message);
}
task_cert_submit_read_redirect_with_message($redirectTo, 'task_cert_notice', 'Wissensbeiträge erfolgreich bestätigt.');
}
TaskCertResponse::json([
'success' => true,
'submission_id' => $submissionId,
'status' => $submissionStatus !== '' ? $submissionStatus : 'auto_passed',
]);
} catch (Throwable $throwable) {
if ($redirectTo !== '') {
task_cert_submit_read_redirect_with_message($redirectTo, 'task_cert_error', $throwable->getMessage());
}
TaskCertResponse::json([
'success' => false,
'error' => $throwable->getMessage(),
], 400);
}

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
class TaskCertAuthContext
{
private int $contactId;
private int $masterMandantId;
private int $currentMandantId;
public function __construct(int $contactId, int $masterMandantId, int $currentMandantId)
{
$this->contactId = $contactId;
$this->masterMandantId = $masterMandantId;
$this->currentMandantId = $currentMandantId;
}
public static function fromGlobals(): self
{
$mainContact = self::asArray($GLOBALS['main_contact'] ?? ($_SESSION['main_contact'] ?? []));
$contactId = self::firstPositiveInt([
$mainContact['id'] ?? null,
$mainContact['main_contact_id'] ?? null,
$GLOBALS['main_contact_id'] ?? null,
$_SESSION['main_contact_id'] ?? null,
$GLOBALS['contact_id'] ?? null,
$_SESSION['contact_id'] ?? null,
$_SESSION['id'] ?? null,
]);
$masterMandantId = self::firstPositiveInt([
$mainContact['master_mandant_id'] ?? null,
$mainContact['main_mandant_id'] ?? null,
$mainContact['mandant_id'] ?? null,
$GLOBALS['master_mandant_id'] ?? null,
$_SESSION['master_mandant_id'] ?? null,
$GLOBALS['main_mandant_id'] ?? null,
$_SESSION['main_mandant_id'] ?? null,
self::extractMandantId($GLOBALS['mandant'] ?? null),
self::extractMandantId($_SESSION['mandant'] ?? null),
]);
$currentMandantId = self::firstPositiveInt([
$mainContact['current_mandant_id'] ?? null,
$mainContact['main_mandant_id'] ?? null,
$GLOBALS['current_mandant_id'] ?? null,
$_SESSION['current_mandant_id'] ?? null,
self::extractMandantId($GLOBALS['current_mandant'] ?? null),
self::extractMandantId($_SESSION['current_mandant'] ?? null),
self::extractMandantId($GLOBALS['mandant'] ?? null),
self::extractMandantId($_SESSION['mandant'] ?? null),
]);
if ($currentMandantId <= 0) {
$currentMandantId = $masterMandantId;
}
if ($masterMandantId <= 0) {
$masterMandantId = $currentMandantId;
}
return new self($contactId, $masterMandantId, $currentMandantId);
}
public function contactId(): int
{
return $this->contactId;
}
public function masterMandantId(): int
{
return $this->masterMandantId;
}
public function currentMandantId(): int
{
return $this->currentMandantId;
}
public function isLoggedIn(): bool
{
return $this->contactId > 0;
}
public function hasPermission(string $permissionKey): bool
{
if (!$this->isLoggedIn()) {
TaskCertLogger::warning('Permission denied: no contact id in auth context', [
'permission' => $permissionKey,
'contact_id' => $this->contactId,
'master_mandant_id' => $this->masterMandantId,
'current_mandant_id' => $this->currentMandantId,
'request_uri' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
]);
return false;
}
$mandantIds = [];
if ($this->currentMandantId > 0) {
$mandantIds[] = $this->currentMandantId;
}
if ($this->masterMandantId > 0 && !in_array($this->masterMandantId, $mandantIds, true)) {
$mandantIds[] = $this->masterMandantId;
}
if ($mandantIds === []) {
$mandantIds[] = 0;
}
$usedFallback = !function_exists('get_permission_state');
$checkedStates = [];
foreach ($mandantIds as $mandantId) {
try {
$state = $usedFallback
? $this->getPermissionStateFallback($permissionKey, $mandantId)
: (int) get_permission_state($permissionKey, $mandantId, $this->contactId);
$checkedStates[] = [
'mandant_id' => $mandantId,
'state' => $state,
];
if ($state === 1) {
if ($usedFallback) {
TaskCertLogger::info('Permission granted via SQL fallback', [
'permission' => $permissionKey,
'contact_id' => $this->contactId,
'mandant_id' => $mandantId,
]);
}
return true;
}
} catch (Throwable $throwable) {
TaskCertLogger::error('Permission check failed', [
'permission' => $permissionKey,
'contact_id' => $this->contactId,
'mandant_id' => $mandantId,
'fallback_used' => $usedFallback ? 1 : 0,
'exception' => $throwable,
]);
}
}
TaskCertLogger::warning('Permission denied after check', [
'permission' => $permissionKey,
'contact_id' => $this->contactId,
'master_mandant_id' => $this->masterMandantId,
'current_mandant_id' => $this->currentMandantId,
'checked_mandant_ids' => $mandantIds,
'checked_states' => $checkedStates,
'fallback_used' => $usedFallback ? 1 : 0,
'request_uri' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
]);
return false;
}
private static function asArray($value): array
{
if (!is_array($value)) {
return [];
}
return $value;
}
private static function firstPositiveInt(array $values): int
{
foreach ($values as $value) {
$intValue = (int) $value;
if ($intValue > 0) {
return $intValue;
}
}
return 0;
}
private static function extractMandantId($value): int
{
if (!is_array($value)) {
return 0;
}
return self::firstPositiveInt([
$value['id'] ?? null,
$value['main_mandant_id'] ?? null,
$value['mandant_id'] ?? null,
]);
}
private function getPermissionStateFallback(string $permissionKey, int $mandantId): int
{
$conn = $GLOBALS['mysql_con'] ?? null;
if (!($conn instanceof mysqli)) {
throw new RuntimeException('Kein mysqli-Handle für Permission-Fallback verfügbar.');
}
$defaultState = 0;
$permissionId = 0;
$stmtPermission = $conn->prepare(
'SELECT id, default_active FROM main_permission WHERE code = ? LIMIT 1'
);
if (!($stmtPermission instanceof mysqli_stmt)) {
throw new RuntimeException('Statement für main_permission konnte nicht erstellt werden.');
}
$stmtPermission->bind_param('s', $permissionKey);
$stmtPermission->execute();
$resultPermission = $stmtPermission->get_result();
if ($resultPermission instanceof mysqli_result) {
$permission = $resultPermission->fetch_assoc();
if (is_array($permission)) {
$permissionId = (int) ($permission['id'] ?? 0);
$defaultState = (int) ($permission['default_active'] ?? 0) === 1 ? 1 : 0;
}
$resultPermission->free();
}
$stmtPermission->close();
if ($permissionId <= 0) {
return 0;
}
$state = $defaultState;
$stmtContact = $conn->prepare(
'SELECT active FROM main_contact_permission
WHERE main_contact_id = ? AND main_mandant_id = ? AND main_permission_id = ?
LIMIT 1'
);
if (!($stmtContact instanceof mysqli_stmt)) {
throw new RuntimeException('Statement für main_contact_permission konnte nicht erstellt werden.');
}
$contactId = $this->contactId;
$stmtContact->bind_param('iii', $contactId, $mandantId, $permissionId);
$stmtContact->execute();
$resultContact = $stmtContact->get_result();
if ($resultContact instanceof mysqli_result) {
$contactPermission = $resultContact->fetch_assoc();
if (is_array($contactPermission)) {
$state = (int) ($contactPermission['active'] ?? 0) === 1 ? 1 : 0;
}
$resultContact->free();
}
$stmtContact->close();
return $state;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
class ContactPrefillColumns
{
public const MAP = [
'' => '— Keine Vorbefüllung —',
'name' => 'Name',
'email' => 'E-Mail',
'vehicle_registration_place' => 'KFZ Nummernschild',
];
public static function isValid(string $column): bool
{
return $column === '' || array_key_exists($column, self::MAP);
}
public static function sanitize(string $column): string
{
return self::isValid($column) ? $column : '';
}
public static function columns(): array
{
return array_keys(array_filter(self::MAP, static function (string $label, string $key): bool {
return $key !== '';
}, ARRAY_FILTER_USE_BOTH));
}
public static function resolve(array $contactRow, string $column): string
{
if ($column === '' || !self::isValid($column)) {
return '';
}
return trim((string) ($contactRow[$column] ?? ''));
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
class CronHeartbeat
{
private static function heartbeatDir(): string
{
return dirname(__DIR__) . '/logs/heartbeat';
}
public static function record(string $jobName, array $summary = []): void
{
$dir = self::heartbeatDir();
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$data = [
'job' => $jobName,
'timestamp' => date('c'),
'unix' => time(),
'summary' => $summary,
];
$file = $dir . '/' . preg_replace('/[^a-zA-Z0-9_-]/', '', $jobName) . '.json';
@file_put_contents($file, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), LOCK_EX);
}
public static function read(string $jobName): ?array
{
$file = self::heartbeatDir() . '/' . preg_replace('/[^a-zA-Z0-9_-]/', '', $jobName) . '.json';
if (!is_file($file)) {
return null;
}
$raw = @file_get_contents($file);
if (!is_string($raw) || $raw === '') {
return null;
}
$data = json_decode($raw, true);
return is_array($data) ? $data : null;
}
public static function readAll(): array
{
$dir = self::heartbeatDir();
if (!is_dir($dir)) {
return [];
}
$results = [];
$files = @glob($dir . '/*.json');
if (!is_array($files)) {
return [];
}
foreach ($files as $file) {
$raw = @file_get_contents($file);
if (!is_string($raw) || $raw === '') {
continue;
}
$data = json_decode($raw, true);
if (is_array($data) && isset($data['job'])) {
$results[(string) $data['job']] = $data;
}
}
return $results;
}
}

Some files were not shown because too many files have changed in this diff Show More