monitoringRepo = $monitoringRepo; $this->cycleCalculator = $cycleCalculator; $this->ruleRepo = $ruleRepo; } public function buildDashboard(array $task, array $rawFilters): array { $taskId = (int) ($task['id'] ?? 0); if ($taskId <= 0) { return $this->buildEmptyDashboard(); } $filters = $this->normalizeFilters($rawFilters); $isCompletionBasedRecurring = (string) ($task['frequency_type'] ?? 'once') === 'recurring' && (string) ($task['recurrence_basis'] ?? 'calendar') === 'completion'; if ($isCompletionBasedRecurring) { $repoFilters = [ 'q' => $filters['q'], 'status' => $filters['status'], 'method_type' => $filters['method_type'], 'submission_status' => $filters['submission_status'], ]; $kpis = $this->monitoringRepo->getKpisForTaskLatest($taskId); $todoRows = $this->monitoringRepo->listTodoAssignmentsLatest($taskId, $repoFilters, 100); $completedRows = $this->monitoringRepo->listCompletedAssignmentsLatest($taskId, $repoFilters, 100); $alerts = $this->monitoringRepo->listApprovalAndOverdueLatest($taskId, $repoFilters, 100); $approvalRows = is_array($alerts['approvals'] ?? null) ? $alerts['approvals'] : []; $overdueRows = is_array($alerts['overdue'] ?? null) ? $alerts['overdue'] : []; $methodStats = $this->monitoringRepo->listMethodCompletionStatsLatest($taskId, $repoFilters, 100); $methodMeta = $this->buildMethodMeta($taskId, $task); $rawSubmissionRows = $this->monitoringRepo->listSubmissionAuditLatest($taskId, $repoFilters, 100); $submissionData = $this->prepareSubmissionRows($rawSubmissionRows, $methodMeta); $submissionRows = $submissionData['submission_rows']; $exportRows = $submissionData['export_rows']; foreach ($methodStats as &$methodStat) { $methodStat['method_label'] = $this->methodLabel((string) ($methodStat['method_type'] ?? '')); $methodStat['assignment_count'] = (int) ($methodStat['assignment_count'] ?? 0); $methodStat['fulfilled_count'] = (int) ($methodStat['fulfilled_count'] ?? 0); $methodStat['completion_rate'] = $methodStat['assignment_count'] > 0 ? round(($methodStat['fulfilled_count'] / $methodStat['assignment_count']) * 100, 1) : 0.0; } unset($methodStat); $filters['cycle_key'] = ''; return [ 'has_cycle' => true, 'no_cycle_reason' => '', 'show_cycle_selector' => false, 'cycle_options' => [], 'selected_cycle_key' => '', 'cycle_selection_warning' => '', 'active_tab_key' => (string) $filters['tab'], 'filters' => $filters, 'kpis' => $kpis, 'method_stats' => $methodStats, 'todo_rows' => $todoRows, 'completed_rows' => $completedRows, 'approval_rows' => $approvalRows, 'overdue_rows' => $overdueRows, 'submission_rows' => $submissionRows, 'export_rows' => $exportRows, 'export_mode' => 'snapshot', 'can_export_answers' => true, 'status_options' => ['' => 'Alle'] + TaskCertConstants::assignmentStatusLabels(), 'method_options' => ['' => 'Alle Methoden'] + TaskCertConstants::methodLabels(), 'submission_status_options' => ['' => 'Alle Einreichungsstatus'] + TaskCertConstants::submissionStatusLabels(), ]; } $cycleSelection = $this->resolveCycleSelection( $taskId, $task, trim((string) $filters['cycle_key']) ); $cycleOptions = $cycleSelection['cycle_options']; $selectedCycleKey = (string) $cycleSelection['selected_cycle_key']; $hasCycle = (bool) $cycleSelection['has_cycle']; $noCycleReason = (string) $cycleSelection['no_cycle_reason']; $cycleSelectionWarning = (string) $cycleSelection['cycle_selection_warning']; if ($selectedCycleKey !== '') { $filters['cycle_key'] = $selectedCycleKey; } else { $filters['cycle_key'] = ''; } $kpis = [ 'total_assigned' => 0, 'completed_count' => 0, 'todo_count' => 0, 'overdue_count' => 0, 'awaiting_approval_count' => 0, 'escalated_count' => 0, 'with_submission_count' => 0, 'completion_rate' => 0.0, 'status_counts' => [ 'open' => 0, 'in_progress' => 0, 'approval_pending' => 0, 'overdue' => 0, 'completed' => 0, 'expired' => 0, 'rejected' => 0, ], ]; $methodStats = []; $todoRows = []; $completedRows = []; $approvalRows = []; $overdueRows = []; $submissionRows = []; $exportRows = []; if ($selectedCycleKey !== '') { $repoFilters = [ 'q' => $filters['q'], 'status' => $filters['status'], 'method_type' => $filters['method_type'], 'submission_status' => $filters['submission_status'], ]; $kpis = $this->monitoringRepo->getKpisForTaskCycle($taskId, $selectedCycleKey); $todoRows = $this->monitoringRepo->listTodoAssignments($taskId, $selectedCycleKey, $repoFilters, 100); $completedRows = $this->monitoringRepo->listCompletedAssignments($taskId, $selectedCycleKey, $repoFilters, 100); $alerts = $this->monitoringRepo->listApprovalAndOverdue($taskId, $selectedCycleKey, $repoFilters, 100); $approvalRows = is_array($alerts['approvals'] ?? null) ? $alerts['approvals'] : []; $overdueRows = is_array($alerts['overdue'] ?? null) ? $alerts['overdue'] : []; $methodStats = $this->monitoringRepo->listMethodCompletionStats($taskId, $selectedCycleKey, $repoFilters, 100); $methodMeta = $this->buildMethodMeta($taskId, $task); $rawSubmissionRows = $this->monitoringRepo->listSubmissionAudit($taskId, $selectedCycleKey, $repoFilters, 100); $submissionData = $this->prepareSubmissionRows($rawSubmissionRows, $methodMeta); $submissionRows = $submissionData['submission_rows']; $exportRows = $submissionData['export_rows']; foreach ($methodStats as &$methodStat) { $methodStat['method_label'] = $this->methodLabel((string) ($methodStat['method_type'] ?? '')); $methodStat['assignment_count'] = (int) ($methodStat['assignment_count'] ?? 0); $methodStat['fulfilled_count'] = (int) ($methodStat['fulfilled_count'] ?? 0); $methodStat['completion_rate'] = $methodStat['assignment_count'] > 0 ? round(($methodStat['fulfilled_count'] / $methodStat['assignment_count']) * 100, 1) : 0.0; } unset($methodStat); } return [ 'has_cycle' => $hasCycle, 'no_cycle_reason' => $noCycleReason, 'show_cycle_selector' => true, 'cycle_options' => $cycleOptions, 'selected_cycle_key' => $selectedCycleKey, 'cycle_selection_warning' => $cycleSelectionWarning, 'active_tab_key' => (string) $filters['tab'], 'filters' => $filters, 'kpis' => $kpis, 'method_stats' => $methodStats, 'todo_rows' => $todoRows, 'completed_rows' => $completedRows, 'approval_rows' => $approvalRows, 'overdue_rows' => $overdueRows, 'submission_rows' => $submissionRows, 'export_rows' => $exportRows, 'export_mode' => 'cycle', 'can_export_answers' => $hasCycle, 'status_options' => [ '' => 'Alle', 'open' => 'Offen', 'in_progress' => 'In Bearbeitung', 'approval_pending' => 'Freigabe ausstehend', 'overdue' => 'Überfällig', 'completed' => 'Abgeschlossen', 'expired' => 'Abgelaufen', 'rejected' => 'Abgelehnt', ], 'method_options' => [ '' => 'Alle Methoden', 'knowledge_read' => $this->methodLabel('knowledge_read'), 'form_submit' => $this->methodLabel('form_submit'), 'quiz' => $this->methodLabel('quiz'), 'manual_approval' => $this->methodLabel('manual_approval'), ], 'submission_status_options' => [ '' => 'Alle Einreichungsstatus', 'submitted' => 'Eingereicht', 'auto_passed' => 'Automatisch bestanden', 'awaiting_approval' => 'Wartet auf Freigabe', 'approved' => 'Freigegeben', 'rejected' => 'Abgelehnt', ], ]; } public function buildAnswersExport(array $task, array $rawFilters): array { $taskId = (int) ($task['id'] ?? 0); if ($taskId <= 0) { throw new InvalidArgumentException('Ungültige Aufgaben-ID.'); } $isCompletionBasedRecurring = (string) ($task['frequency_type'] ?? 'once') === 'recurring' && (string) ($task['recurrence_basis'] ?? 'calendar') === 'completion'; $methodMeta = $this->buildMethodMeta($taskId, $task); if ($isCompletionBasedRecurring) { $rawRows = $this->monitoringRepo->listSubmissionAuditLatestForExport($taskId); $submissionData = $this->prepareSubmissionRows($rawRows, $methodMeta); return [ 'mode' => 'snapshot', 'cycle_key' => '', 'rows' => $submissionData['export_rows'], ]; } $requestedCycleKey = trim((string) ($rawFilters['cycle_key'] ?? '')); $cycleSelection = $this->resolveCycleSelection($taskId, $task, $requestedCycleKey); if ($requestedCycleKey !== '' && !(bool) $cycleSelection['requested_cycle_is_valid']) { throw new InvalidArgumentException('Ungültiger Zyklus.'); } $selectedCycleKey = trim((string) ($cycleSelection['selected_cycle_key'] ?? '')); if ($selectedCycleKey === '') { throw new InvalidArgumentException('Kein Zyklus verfügbar.'); } $rawRows = $this->monitoringRepo->listSubmissionAuditForCycleExport($taskId, $selectedCycleKey); $submissionData = $this->prepareSubmissionRows($rawRows, $methodMeta); return [ 'mode' => 'cycle', 'cycle_key' => $selectedCycleKey, 'rows' => $submissionData['export_rows'], ]; } private function buildEmptyDashboard(): array { return [ 'has_cycle' => false, 'no_cycle_reason' => 'Aktuell ist kein Zyklus verfügbar.', 'show_cycle_selector' => false, 'cycle_options' => [], 'selected_cycle_key' => '', 'cycle_selection_warning' => '', 'active_tab_key' => 'overview', 'filters' => [ 'cycle_key' => '', 'q' => '', 'status' => '', 'method_type' => '', 'submission_status' => '', 'tab' => 'overview', ], 'kpis' => [], 'method_stats' => [], 'todo_rows' => [], 'completed_rows' => [], 'approval_rows' => [], 'overdue_rows' => [], 'submission_rows' => [], 'export_rows' => [], 'export_mode' => 'cycle', 'can_export_answers' => false, 'status_options' => [], 'method_options' => [], 'submission_status_options' => [], ]; } private function normalizeFilters(array $rawFilters): array { $allowedTabs = ['overview', 'todo', 'completed', 'approvals', 'overdue', 'answers']; $status = trim((string) ($rawFilters['status'] ?? '')); if (!TaskCertConstants::isValidAssignmentStatus($status)) { $status = ''; } $methodType = trim((string) ($rawFilters['method_type'] ?? '')); if (!TaskCertConstants::isValidMethodType($methodType)) { $methodType = ''; } $submissionStatus = trim((string) ($rawFilters['submission_status'] ?? '')); if (!TaskCertConstants::isValidSubmissionStatus($submissionStatus)) { $submissionStatus = ''; } $tab = trim((string) ($rawFilters['tab'] ?? 'overview')); if ($tab === 'alerts') { $tab = 'approvals'; } if (!in_array($tab, $allowedTabs, true)) { $tab = 'overview'; } $query = trim((string) ($rawFilters['q'] ?? '')); if (function_exists('mb_substr')) { $query = mb_substr($query, 0, 120); } else { $query = substr($query, 0, 120); } return [ 'cycle_key' => trim((string) ($rawFilters['cycle_key'] ?? '')), 'q' => $query, 'status' => $status, 'method_type' => $methodType, 'submission_status' => $submissionStatus, 'tab' => $tab, ]; } private function resolveCycleSelection(int $taskId, array $task, string $requestedCycleKey): array { $requestedCycleKey = trim($requestedCycleKey); $requestedCycleIsValid = true; $cycleRows = $this->monitoringRepo->listCycleKeysForTask($taskId, 24); $cycleMap = []; foreach ($cycleRows as $row) { $cycleKey = trim((string) ($row['cycle_key'] ?? '')); if ($cycleKey === '') { continue; } $cycleMap[$cycleKey] = $row; } if ($requestedCycleKey !== '' && !isset($cycleMap[$requestedCycleKey])) { $requestedCycleRow = $this->monitoringRepo->findCycleKeyForTask($taskId, $requestedCycleKey); if (is_array($requestedCycleRow)) { $cycleMap[$requestedCycleKey] = $requestedCycleRow; } else { $requestedCycleIsValid = false; } } $currentCycle = $this->cycleCalculator->resolveCurrentCycle($task, new DateTimeImmutable()); $currentCycleKey = trim((string) ($currentCycle['cycle_key'] ?? '')); if ($currentCycleKey !== '' && !isset($cycleMap[$currentCycleKey])) { $cycleMap[$currentCycleKey] = [ 'cycle_key' => $currentCycleKey, 'cycle_start_at' => $this->toDateTimeString($currentCycle['cycle_start_at'] ?? null), 'cycle_end_at' => $this->toDateTimeString($currentCycle['cycle_end_at'] ?? null), 'assignment_count' => 0, ]; } $cycleOptions = []; foreach ($cycleMap as $cycleKey => $cycleRow) { $cycleOptions[] = [ 'cycle_key' => $cycleKey, 'label' => $this->formatCycleLabel($cycleKey, $cycleRow), 'cycle_start_at' => (string) ($cycleRow['cycle_start_at'] ?? ''), 'cycle_end_at' => (string) ($cycleRow['cycle_end_at'] ?? ''), 'assignment_count' => (int) ($cycleRow['assignment_count'] ?? 0), ]; } usort($cycleOptions, static function (array $a, array $b): int { $aStart = (string) ($a['cycle_start_at'] ?? ''); $bStart = (string) ($b['cycle_start_at'] ?? ''); if ($aStart !== '' && $bStart !== '' && $aStart !== $bStart) { return strcmp($bStart, $aStart); } return strcmp((string) $b['cycle_key'], (string) $a['cycle_key']); }); $selectedCycleKey = ''; if ($requestedCycleKey !== '' && isset($cycleMap[$requestedCycleKey])) { $selectedCycleKey = $requestedCycleKey; } elseif ($currentCycleKey !== '') { $selectedCycleKey = $currentCycleKey; } elseif ($cycleOptions !== []) { $selectedCycleKey = (string) ($cycleOptions[0]['cycle_key'] ?? ''); } $hasCycle = $selectedCycleKey !== ''; $noCycleReason = ''; if (!$hasCycle) { $startDateRaw = trim((string) ($task['start_date'] ?? '')); if ($startDateRaw !== '') { try { $startDate = new DateTimeImmutable($startDateRaw); $today = new DateTimeImmutable('today'); if ($startDate > $today) { $noCycleReason = 'Der erste Zyklus startet am ' . $startDate->format('d.m.Y') . '.'; } } catch (Throwable $throwable) { $noCycleReason = ''; } } if ($noCycleReason === '') { $noCycleReason = 'Aktuell ist kein Zyklus verfügbar. Bitte Startdatum, Rhythmus und Zuweisungen prüfen.'; } } $cycleSelectionWarning = ''; if ($requestedCycleKey !== '' && !$requestedCycleIsValid) { if ($selectedCycleKey !== '') { $cycleSelectionWarning = 'Der angeforderte Zyklus "' . $requestedCycleKey . '" ist ungültig oder nicht vorhanden. Es wird "' . $selectedCycleKey . '" angezeigt.'; } else { $cycleSelectionWarning = 'Der angeforderte Zyklus "' . $requestedCycleKey . '" ist ungültig oder nicht vorhanden.'; } } return [ 'cycle_options' => $cycleOptions, 'selected_cycle_key' => $selectedCycleKey, 'has_cycle' => $hasCycle, 'no_cycle_reason' => $noCycleReason, 'cycle_selection_warning' => $cycleSelectionWarning, 'requested_cycle_is_valid' => $requestedCycleIsValid, ]; } private function prepareSubmissionRows(array $rawSubmissionRows, array $methodMeta): array { $submissionRows = []; $exportRows = []; foreach ($rawSubmissionRows as $row) { $payload = $this->decodePayload((string) ($row['payload_json'] ?? '')); $normalized = $this->normalizeSubmission($row, $payload, $methodMeta[(int) ($row['method_id'] ?? 0)] ?? []); $answerDetailJson = $this->encodeJson((array) ($normalized['detail'] ?? []), false); $answerDetailPrettyJson = $this->encodeJson((array) ($normalized['detail'] ?? []), true); $methodLabel = $this->methodLabel((string) ($row['method_type'] ?? '')); $row['answer_summary'] = (string) ($normalized['summary'] ?? ''); $row['answer_items'] = is_array($normalized['items'] ?? null) ? $normalized['items'] : []; $row['answer_detail'] = is_array($normalized['detail'] ?? null) ? $normalized['detail'] : []; $row['answer_detail_json'] = $answerDetailJson; $row['answer_detail_pretty_json'] = $answerDetailPrettyJson; $row['method_label'] = $methodLabel; $submissionRows[] = $row; $exportRows[] = [ 'submission_id' => (int) ($row['id'] ?? 0), 'assignment_id' => (int) ($row['assignment_id'] ?? 0), 'cycle_key' => (string) ($row['cycle_key'] ?? ''), 'contact_id' => (int) ($row['main_contact_id'] ?? 0), 'contact_name' => trim((string) ($row['contact_name'] ?? '')), 'contact_email' => trim((string) ($row['contact_email'] ?? '')), 'method_id' => (int) ($row['method_id'] ?? 0), 'method_type' => (string) ($row['method_type'] ?? ''), 'method_label' => $methodLabel, 'submission_status' => (string) ($row['status'] ?? ''), 'assignment_status' => (string) ($row['assignment_status'] ?? ''), 'submitted_at' => (string) ($row['submitted_at'] ?? ''), 'due_at' => (string) ($row['due_at'] ?? ''), 'overdue_at' => (string) ($row['overdue_at'] ?? ''), 'completed_at' => (string) ($row['completed_at'] ?? ''), 'escalation_level' => (int) ($row['escalation_level'] ?? 0), 'score' => $row['score'] !== null ? (int) $row['score'] : null, 'max_score' => $row['max_score'] !== null ? (int) $row['max_score'] : null, 'review_note' => trim((string) ($row['review_note'] ?? '')), 'answer_summary' => (string) ($normalized['summary'] ?? ''), 'answer_detail_json' => $answerDetailJson, ]; } return [ 'submission_rows' => $submissionRows, 'export_rows' => $exportRows, ]; } private function buildMethodMeta(int $taskId, array $task): array { $methods = []; if (isset($task['rules']['methods']) && is_array($task['rules']['methods'])) { $methods = $task['rules']['methods']; } if ($methods === []) { $methods = $this->ruleRepo->getMethodsByTask($taskId); } $meta = []; foreach ($methods as $method) { $methodId = (int) ($method['id'] ?? 0); if ($methodId <= 0) { continue; } $entry = [ 'method_type' => (string) ($method['method_type'] ?? ''), 'form_field_labels' => [], 'quiz_question_labels' => [], 'quiz_option_labels' => [], ]; foreach ((array) ($method['form_fields'] ?? []) as $field) { $fieldKey = trim((string) ($field['field_key'] ?? '')); if ($fieldKey === '') { continue; } $entry['form_field_labels'][$fieldKey] = trim((string) ($field['label'] ?? $fieldKey)); } foreach ((array) ($method['quiz_questions'] ?? []) as $question) { $questionId = (int) ($question['id'] ?? 0); if ($questionId > 0) { $entry['quiz_question_labels'][$questionId] = trim((string) ($question['question_text'] ?? ('Frage #' . $questionId))); } foreach ((array) ($question['options'] ?? []) as $option) { $optionId = (int) ($option['id'] ?? 0); if ($optionId <= 0) { continue; } $entry['quiz_option_labels'][$optionId] = trim((string) ($option['option_text'] ?? ('Option #' . $optionId))); } } $meta[$methodId] = $entry; } return $meta; } private function normalizeSubmission(array $row, array $payload, array $methodMeta): array { $methodType = (string) ($row['method_type'] ?? ''); $summary = 'Einreichung vorhanden'; $items = []; $detail = $payload; if ($methodType === 'knowledge_read') { $postIds = []; foreach ((array) ($payload['post_ids'] ?? []) as $postId) { $postIdInt = (int) $postId; if ($postIdInt > 0) { $postIds[] = $postIdInt; } } $postIds = array_values(array_unique($postIds)); $summary = count($postIds) . ' Beiträge bestätigt'; $items[] = [ 'label' => 'Beitrags-IDs', 'value' => $postIds === [] ? '-' : implode(', ', $postIds), ]; $detail = [ 'post_ids' => $postIds, ]; } elseif ($methodType === 'form_submit') { $values = is_array($payload['form_values'] ?? null) ? $payload['form_values'] : []; $labels = is_array($methodMeta['form_field_labels'] ?? null) ? $methodMeta['form_field_labels'] : []; $summaryParts = []; $detailRows = []; $submissionId = (int) ($row['id'] ?? 0); foreach ($values as $fieldKey => $value) { $fieldKey = (string) $fieldKey; $label = trim((string) ($labels[$fieldKey] ?? $fieldKey)); $displayValue = $this->normalizeDisplayValue($value); $item = [ 'label' => $label, 'value' => $displayValue, ]; // Datei-Upload-Felder: pro Datei Referenzdaten mitgeben, damit die // Seiten daraus Download-Links bauen können. if ($this->isFileValue($value)) { $files = []; foreach (array_values($value) as $i => $fileMeta) { if (!is_array($fileMeta)) { continue; } $fileName = trim((string) ($fileMeta['file_name'] ?? '')); $files[] = [ 'name' => $fileName !== '' ? $fileName : 'Datei', 'submission_id' => $submissionId, 'field_key' => $fieldKey, 'index' => $i, ]; } if ($files !== []) { $item['files'] = $files; } } $items[] = $item; $detailRows[] = [ 'field_key' => $fieldKey, 'label' => $label, 'value' => $value, ]; if (count($summaryParts) < 2) { $summaryParts[] = $label . ': ' . $displayValue; } } if ($summaryParts === []) { $summary = 'Keine Feldwerte'; } else { $summary = implode(' | ', $summaryParts); if (count($items) > 2) { $summary .= ' | +' . (count($items) - 2) . ' weitere'; } } $detail = [ 'form_values' => $detailRows, ]; } elseif ($methodType === 'quiz') { $score = $row['score'] !== null ? (int) $row['score'] : (int) ($payload['score'] ?? 0); $maxScore = $row['max_score'] !== null ? (int) $row['max_score'] : (int) ($payload['max_score'] ?? 0); if ($maxScore > 0) { $summary = $score . '/' . $maxScore . ' korrekt'; } else { $summary = 'Quiz eingereicht'; } $questionLabels = is_array($methodMeta['quiz_question_labels'] ?? null) ? $methodMeta['quiz_question_labels'] : []; $optionLabels = is_array($methodMeta['quiz_option_labels'] ?? null) ? $methodMeta['quiz_option_labels'] : []; $answers = is_array($payload['answers'] ?? null) ? $payload['answers'] : []; $normalizedAnswers = []; foreach ($answers as $questionId => $optionIds) { $questionIdInt = (int) $questionId; if (!is_array($optionIds)) { $optionIds = [$optionIds]; } $resolvedOptions = []; foreach ($optionIds as $optionId) { $optionIdInt = (int) $optionId; if ($optionIdInt <= 0) { continue; } $resolvedOptions[] = $optionLabels[$optionIdInt] ?? ('Option #' . $optionIdInt); } $resolvedOptions = array_values(array_unique($resolvedOptions)); $questionLabel = $questionLabels[$questionIdInt] ?? ('Frage #' . $questionIdInt); $display = $resolvedOptions === [] ? '-' : implode(', ', $resolvedOptions); $items[] = [ 'label' => $questionLabel, 'value' => $display, ]; $normalizedAnswers[] = [ 'question_id' => $questionIdInt, 'question' => $questionLabel, 'answers' => $resolvedOptions, ]; } $detail = [ 'score' => $score, 'max_score' => $maxScore, 'answers' => $normalizedAnswers, ]; } elseif ($methodType === 'manual_approval') { $note = trim((string) ($payload['note'] ?? '')); $reviewNote = trim((string) ($row['review_note'] ?? '')); $summary = $note !== '' ? $this->truncate($note, 90) : 'Antrag eingereicht'; $items[] = [ 'label' => 'Antrag', 'value' => $note !== '' ? $note : '-', ]; if ($reviewNote !== '') { $items[] = [ 'label' => 'Review-Notiz', 'value' => $reviewNote, ]; } $detail = [ 'note' => $note, 'review_note' => $reviewNote, ]; } return [ 'summary' => $summary, 'items' => $items, 'detail' => $detail, ]; } private function encodeJson(array $payload, bool $pretty): string { $flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; if ($pretty) { $flags |= JSON_PRETTY_PRINT; } $encoded = json_encode($payload, $flags); if ($encoded === false) { return '{}'; } return $encoded; } private function isFileValue($value): bool { if (!is_array($value) || $value === []) { return false; } $first = reset($value); return is_array($first) && array_key_exists('file_path', $first); } private function normalizeDisplayValue($value): string { if (is_array($value)) { $items = []; foreach ($value as $item) { // Datei-Metadaten als Dateiname darstellen statt "Array". $itemString = is_array($item) ? trim((string) ($item['file_name'] ?? '')) : trim((string) $item); if ($itemString !== '') { $items[] = $itemString; } } return $items === [] ? '-' : implode(', ', array_values(array_unique($items))); } $stringValue = trim((string) $value); return $stringValue !== '' ? $stringValue : '-'; } private function decodePayload(string $payloadJson): array { if (trim($payloadJson) === '') { return []; } $decoded = json_decode($payloadJson, true); return is_array($decoded) ? $decoded : []; } private function formatCycleLabel(string $cycleKey, array $cycleRow): string { $start = $this->formatDateShort((string) ($cycleRow['cycle_start_at'] ?? '')); $end = $this->formatDateShort((string) ($cycleRow['cycle_end_at'] ?? '')); if ($start !== '' && $end !== '') { return $cycleKey . ' (' . $start . ' - ' . $end . ')'; } return $cycleKey; } private function formatDateShort(string $value): string { if (trim($value) === '') { return ''; } try { return (new DateTimeImmutable($value))->format('d.m.Y'); } catch (Throwable $throwable) { return $value; } } private function toDateTimeString($value): ?string { if ($value instanceof DateTimeImmutable) { return $value->format('Y-m-d H:i:s'); } if (!is_string($value) || trim($value) === '') { return null; } try { return (new DateTimeImmutable($value))->format('Y-m-d H:i:s'); } catch (Throwable $throwable) { return null; } } private function methodLabel(string $methodType): string { $map = TaskCertConstants::methodLabels(); return $map[$methodType] ?? $methodType; } private function truncate(string $value, int $maxLength): string { if ($maxLength < 4) { return $value; } if (function_exists('mb_strlen') && function_exists('mb_substr')) { if (mb_strlen($value) <= $maxLength) { return $value; } return rtrim(mb_substr($value, 0, $maxLength - 3)) . '...'; } if (strlen($value) <= $maxLength) { return $value; } return rtrim(substr($value, 0, $maxLength - 3)) . '...'; } }