diff --git a/lib/Service/Image/ImageUploadTrait.php b/lib/Service/Image/ImageUploadTrait.php index 0c71e30..58fe988 100644 --- a/lib/Service/Image/ImageUploadTrait.php +++ b/lib/Service/Image/ImageUploadTrait.php @@ -180,9 +180,6 @@ trait ImageUploadTrait $saved = imagejpeg($dst, $targetPath, 85); } - imagedestroy($src); - imagedestroy($dst); - if ($saved) { @chmod($targetPath, 0644); } @@ -226,9 +223,6 @@ trait ImageUploadTrait $saved = imagepng($dst, $targetPath, 6); } - imagedestroy($src); - imagedestroy($dst); - return $saved; } diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index 4a91e1e..b369a2b 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -55,7 +55,6 @@ "Save": "Speichern", "Settings saved": "Einstellungen gespeichert", "Configuration incomplete": "Konfiguration unvollständig", - "OData connection": "OData-Verbindung", "OData Base URL": "OData-Basis-URL", "Base URL without Company path segment": "Basis-URL ohne Company-Pfadsegment", "Company name": "Firmenname", @@ -64,11 +63,10 @@ "Auth mode": "Auth-Modus", "Basic Auth": "Basic Auth", "OAuth2 (client credentials)": "OAuth2 (Client Credentials)", - "Basic Auth credentials": "Basic-Auth-Zugangsdaten", "Username": "Benutzername", "Password": "Passwort", - "Leave unchanged to keep current password": "Leer lassen, um das aktuelle Passwort beizubehalten", - "OAuth2 credentials": "OAuth2-Zugangsdaten", + "Leave empty to keep current password": "Leer lassen, um das aktuelle Passwort beizubehalten", + "Saved": "Gespeichert", "Azure Tenant ID": "Azure-Mandanten-ID", "Client ID": "Client-ID", "Client Secret": "Client-Secret", @@ -286,15 +284,14 @@ "Min. age": "Mindestalter", "Min. open": "Mind. offen", "Team": "Team", - "Team dashboard": "Team-Dashboard", + "Team dashboard": "Auslastung", "Team workload": "Team-Auslastung", "Support agent": "Support-Mitarbeiter", - "Open": "Offen", "Critical (>48h open)": "Kritisch (>48h offen)", "Avg. age (h)": "Ø Alter (h)", "Resolved in period": "Gelöst im Zeitraum", "Not assigned": "Nicht zugewiesen", - "Could not load team dashboard.": "Team-Dashboard konnte nicht geladen werden.", + "Could not load team dashboard.": "Auslastung konnte nicht geladen werden.", "No team data available.": "Keine Team-Daten verfügbar.", "Top customers": "Top-Kunden", "Top categories": "Top-Kategorien", @@ -309,6 +306,11 @@ "Trend": "Trend", "Risk radar": "Risiko-Radar", "Risk score": "Risiko-Score", + "Risk traffic light logic": "Ampel-Logik", + "High risk (55-100)": "Hohes Risiko (55-100)", + "Medium risk (25-54)": "Mittleres Risiko (25-54)", + "Low risk (0-24)": "Niedriges Risiko (0-24)", + "Score = weighted sum: Open pressure (35), Trend (30), SLA overdue (25), Inactivity (10).": "Score = gewichtete Summe: Offene Tickets (35), Trend (30), SLA überfällig (25), Inaktivität (10).", "High risk": "Hohes Risiko", "Medium risk": "Mittleres Risiko", "Low risk": "Niedriges Risiko", @@ -327,14 +329,14 @@ "more than resolved": "mehr als gelöst", "SLA breaches": "SLA-Verletzungen", "Oldest ticket": "Ältestes Ticket", - "Tenant override": "Mandanten-Override", + "General connection": "Allgemeine Verbindung", + "Tenant connection": "Mandanten-Verbindung", "Configuration source": "Konfigurationsquelle", - "Current tenant": "Aktueller Mandant", "Use tenant-specific configuration": "Mandantenspezifische Konfiguration verwenden", "When enabled, this tenant uses its own BC connection instead of the global configuration.": "Wenn aktiviert, nutzt dieser Mandant seine eigene BC-Verbindung anstelle der globalen Konfiguration.", - "Tenant connection": "Mandanten-Verbindung", - "Tenant authentication": "Mandanten-Authentifizierung", "This tenant inherits the global configuration. Enable the toggle above to use tenant-specific settings.": "Dieser Mandant erbt die globale Konfiguration. Aktivieren Sie den Schalter oben, um mandantenspezifische Einstellungen zu verwenden.", "Tenant settings saved": "Mandanten-Einstellungen gespeichert", - "Token endpoint must use HTTPS": "Token-Endpunkt muss HTTPS verwenden" + "Token endpoint must use HTTPS": "Token-Endpunkt muss HTTPS verwenden", + "The current tenant uses its own configuration. Changes here only affect tenants without a tenant-specific connection.": "Der aktuelle Mandant verwendet eine eigene Verbindung. Änderungen hier betreffen nur Mandanten ohne eigene Verbindung.", + "Failed to save tenant settings": "Mandanten-Einstellungen konnten nicht gespeichert werden" } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index e8e71dc..fe93ec6 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -55,7 +55,6 @@ "Save": "Save", "Settings saved": "Settings saved", "Configuration incomplete": "Configuration incomplete", - "OData connection": "OData connection", "OData Base URL": "OData Base URL", "Base URL without Company path segment": "Base URL without Company path segment", "Company name": "Company name", @@ -64,11 +63,10 @@ "Auth mode": "Auth mode", "Basic Auth": "Basic Auth", "OAuth2 (client credentials)": "OAuth2 (client credentials)", - "Basic Auth credentials": "Basic Auth credentials", "Username": "Username", "Password": "Password", - "Leave unchanged to keep current password": "Leave unchanged to keep current password", - "OAuth2 credentials": "OAuth2 credentials", + "Leave empty to keep current password": "Leave empty to keep current password", + "Saved": "Saved", "Azure Tenant ID": "Azure Tenant ID", "Client ID": "Client ID", "Client Secret": "Client Secret", @@ -289,7 +287,6 @@ "Team dashboard": "Team dashboard", "Team workload": "Team workload", "Support agent": "Support agent", - "Open": "Open", "Critical (>48h open)": "Critical (>48h open)", "Avg. age (h)": "Avg. age (h)", "Resolved in period": "Resolved in period", @@ -309,6 +306,11 @@ "Trend": "Trend", "Risk radar": "Risk radar", "Risk score": "Risk score", + "Risk traffic light logic": "Traffic light logic", + "High risk (55-100)": "High risk (55-100)", + "Medium risk (25-54)": "Medium risk (25-54)", + "Low risk (0-24)": "Low risk (0-24)", + "Score = weighted sum: Open pressure (35), Trend (30), SLA overdue (25), Inactivity (10).": "Score = weighted sum: Open pressure (35), Trend (30), SLA overdue (25), Inactivity (10).", "High risk": "High risk", "Medium risk": "Medium risk", "Low risk": "Low risk", @@ -327,14 +329,14 @@ "more than resolved": "more than resolved", "SLA breaches": "SLA breaches", "Oldest ticket": "Oldest ticket", - "Tenant override": "Tenant override", + "General connection": "General connection", + "Tenant connection": "Tenant connection", "Configuration source": "Configuration source", - "Current tenant": "Current tenant", "Use tenant-specific configuration": "Use tenant-specific configuration", "When enabled, this tenant uses its own BC connection instead of the global configuration.": "When enabled, this tenant uses its own BC connection instead of the global configuration.", - "Tenant connection": "Tenant connection", - "Tenant authentication": "Tenant authentication", "This tenant inherits the global configuration. Enable the toggle above to use tenant-specific settings.": "This tenant inherits the global configuration. Enable the toggle above to use tenant-specific settings.", "Tenant settings saved": "Tenant settings saved", - "Token endpoint must use HTTPS": "Token endpoint must use HTTPS" + "Token endpoint must use HTTPS": "Token endpoint must use HTTPS", + "The current tenant uses its own configuration. Changes here only affect tenants without a tenant-specific connection.": "The current tenant uses its own configuration. Changes here only affect tenants without a tenant-specific connection.", + "Failed to save tenant settings": "Failed to save tenant settings" } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php index fb990ea..ae90e0e 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php @@ -615,27 +615,52 @@ class BcODataGateway * Get tickets across all customers for risk radar aggregation. * * Uses PBI_LV_Tickets with client-driven paging ($skip/$top). - * Hardcap: 5000 rows (10 pages × 500 per page). + * Tries a server-side date filter first (2× periodDays for trend window). + * Falls back to unfiltered pagination if BC rejects the filter. + * Follows @odata.nextLink for robust pagination. * * @return array{tickets: array>, truncated: bool} */ - public function getTicketsForRiskRadar(): array + public function getTicketsForRiskRadar(int $periodDays = 90): array { - $pageSize = 500; - $maxPages = 10; $select = 'No,Customer_No,Cust_Name,Escalation_Code,Created_On,Last_Activity_Date,Process_Stage,Ticket_State,Support_User_Name,Category_1_Code,Process_Code'; + $baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV); + + // Strategy 1: server-side date filter (drastically reduces data volume) + $cutoff = (new \DateTimeImmutable('-' . ($periodDays * 2) . ' days'))->format('Y-m-d'); + $filteredUrl = $baseUrl + . '?$top=500' + . '&$select=' . rawurlencode($select) + . '&$filter=' . rawurlencode("Created_On ge " . $cutoff) + . '&$orderby=' . rawurlencode('Created_On desc'); + + $result = $this->paginateOData($filteredUrl, 20); + if ($result['tickets'] !== []) { + return $result; + } + + // Strategy 2: fallback without filter (BC may not support date filters on this entity) + $unfilteredUrl = $baseUrl + . '?$top=500' + . '&$select=' . rawurlencode($select) + . '&$orderby=' . rawurlencode('Created_On desc'); + + return $this->paginateOData($unfilteredUrl, 10); + } + + /** + * Paginate an OData request following @odata.nextLink. + * + * @return array{tickets: array>, truncated: bool} + */ + private function paginateOData(string $startUrl, int $maxPages): array + { $allTickets = []; $truncated = false; + $nextUrl = $startUrl; for ($page = 0; $page < $maxPages; $page++) { - $skip = $page * $pageSize; - $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV) - . '?$top=' . $pageSize - . '&$skip=' . $skip - . '&$select=' . rawurlencode($select) - . '&$orderby=' . rawurlencode('Created_On desc'); - - $response = $this->request('GET', $url); + $response = $this->request('GET', $nextUrl); if ($response === null) { break; } @@ -649,10 +674,13 @@ class BcODataGateway $allTickets[] = $row; } - if (count($rows) < $pageSize) { + $link = trim((string) ($response['@odata.nextLink'] ?? $response['odata.nextLink'] ?? '')); + if ($link === '' || count($rows) < 500) { break; } + $nextUrl = $link; + if ($page === $maxPages - 1) { $truncated = true; } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTenantSettingsGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTenantSettingsGateway.php index 7c83d65..81898b7 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTenantSettingsGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTenantSettingsGateway.php @@ -9,21 +9,24 @@ use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface; * * Wraps the tenant settings repository and handles encryption/decryption * of secrets (basic_password, oauth_client_secret) via SettingsCryptoGateway. + * + * Uses a per-request row cache to avoid N+1 queries when multiple getters + * are called for the same tenant (e.g. during settings resolution). */ class HelpdeskTenantSettingsGateway { + /** @var array|false> Cache: tenantId → row or false (not found) */ + private array $rowCache = []; + public function __construct( private readonly HelpdeskTenantSettingsRepository $repository, private readonly SettingsCryptoGatewayInterface $settingsCryptoGateway ) { } - /** - * Check whether an active override exists for the given tenant. - */ public function isOverrideEnabled(int $tenantId): bool { - $row = $this->repository->findByTenantId($tenantId); + $row = $this->resolveRow($tenantId); if ($row === null) { return false; } @@ -36,12 +39,12 @@ class HelpdeskTenantSettingsGateway */ public function findByTenantId(int $tenantId): ?array { - return $this->repository->findByTenantId($tenantId); + return $this->resolveRow($tenantId); } public function getAuthMode(int $tenantId): string { - $row = $this->repository->findByTenantId($tenantId); + $row = $this->resolveRow($tenantId); $value = trim((string) ($row['auth_mode'] ?? '')); return $value === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2 @@ -51,7 +54,7 @@ class HelpdeskTenantSettingsGateway public function getODataBaseUrl(int $tenantId): string { - $row = $this->repository->findByTenantId($tenantId); + $row = $this->resolveRow($tenantId); $value = trim((string) ($row['odata_base_url'] ?? '')); return $value !== '' ? rtrim($value, '/') : HelpdeskSettingsGateway::DEFAULT_ODATA_BASE_URL; @@ -59,7 +62,7 @@ class HelpdeskTenantSettingsGateway public function getCompanyName(int $tenantId): string { - $row = $this->repository->findByTenantId($tenantId); + $row = $this->resolveRow($tenantId); $value = trim((string) ($row['company_name'] ?? '')); return $value !== '' ? $value : HelpdeskSettingsGateway::DEFAULT_COMPANY_NAME; @@ -67,7 +70,7 @@ class HelpdeskTenantSettingsGateway public function getBasicUser(int $tenantId): ?string { - $row = $this->repository->findByTenantId($tenantId); + $row = $this->resolveRow($tenantId); $value = trim((string) ($row['basic_user'] ?? '')); return $value !== '' ? $value : null; @@ -75,7 +78,7 @@ class HelpdeskTenantSettingsGateway public function getBasicPassword(int $tenantId): ?string { - $row = $this->repository->findByTenantId($tenantId); + $row = $this->resolveRow($tenantId); $encrypted = trim((string) ($row['basic_password_enc'] ?? '')); if ($encrypted === '') { return null; @@ -94,7 +97,7 @@ class HelpdeskTenantSettingsGateway public function getOAuthTenantId(int $tenantId): ?string { - $row = $this->repository->findByTenantId($tenantId); + $row = $this->resolveRow($tenantId); $value = trim((string) ($row['oauth_tenant_id'] ?? '')); return $value !== '' ? $value : null; @@ -102,7 +105,7 @@ class HelpdeskTenantSettingsGateway public function getOAuthClientId(int $tenantId): ?string { - $row = $this->repository->findByTenantId($tenantId); + $row = $this->resolveRow($tenantId); $value = trim((string) ($row['oauth_client_id'] ?? '')); return $value !== '' ? $value : null; @@ -110,7 +113,7 @@ class HelpdeskTenantSettingsGateway public function getOAuthClientSecret(int $tenantId): ?string { - $row = $this->repository->findByTenantId($tenantId); + $row = $this->resolveRow($tenantId); $encrypted = trim((string) ($row['oauth_client_secret_enc'] ?? '')); if ($encrypted === '') { return null; @@ -129,7 +132,7 @@ class HelpdeskTenantSettingsGateway public function getOAuthTokenEndpoint(int $tenantId): ?string { - $row = $this->repository->findByTenantId($tenantId); + $row = $this->resolveRow($tenantId); $value = trim((string) ($row['oauth_token_endpoint'] ?? '')); return $value !== '' ? $value : null; @@ -137,20 +140,18 @@ class HelpdeskTenantSettingsGateway public function getSystemRecommendationsConfig(int $tenantId): ?string { - $row = $this->repository->findByTenantId($tenantId); + $row = $this->resolveRow($tenantId); + $value = trim((string) ($row['system_recommendations_config'] ?? '')); - return ($row['system_recommendations_config'] ?? null) !== null - ? (string) $row['system_recommendations_config'] - : null; + return $value !== '' ? $value : null; } public function getControllingRiskConfig(int $tenantId): ?string { - $row = $this->repository->findByTenantId($tenantId); + $row = $this->resolveRow($tenantId); + $value = trim((string) ($row['controlling_risk_config'] ?? '')); - return ($row['controlling_risk_config'] ?? null) !== null - ? (string) $row['controlling_risk_config'] - : null; + return $value !== '' ? $value : null; } /** @@ -174,8 +175,6 @@ class HelpdeskTenantSettingsGateway } catch (\Throwable) { return false; } - } else { - $data['basic_password_enc'] = null; } } @@ -188,16 +187,42 @@ class HelpdeskTenantSettingsGateway } catch (\Throwable) { return false; } - } else { - $data['oauth_client_secret_enc'] = null; } } + // Invalidate cache after write + unset($this->rowCache[$tenantId]); + return $this->repository->upsert($tenantId, $data); } public function deleteByTenantId(int $tenantId): bool { + unset($this->rowCache[$tenantId]); + return $this->repository->deleteByTenantId($tenantId); } + + /** + * Load row once per tenant per request. Returns null if no row exists. + * + * @return array|null + */ + private function resolveRow(int $tenantId): ?array + { + if ($tenantId <= 0) { + return null; + } + + if (array_key_exists($tenantId, $this->rowCache)) { + $cached = $this->rowCache[$tenantId]; + + return $cached === false ? null : $cached; + } + + $row = $this->repository->findByTenantId($tenantId); + $this->rowCache[$tenantId] = $row ?? false; + + return $row; + } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTenantSettingsRepository.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTenantSettingsRepository.php index 81a4d27..f7047f9 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTenantSettingsRepository.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTenantSettingsRepository.php @@ -89,39 +89,60 @@ class HelpdeskTenantSettingsRepository } /** + * Update only the columns present in $data — leaves other columns untouched. + * This prevents overwriting secrets (basic_password_enc, oauth_client_secret_enc) + * when they are not explicitly included in the save payload. + * * @param array $data */ private function update(int $tenantId, array $data): bool { - $row = $this->buildRow($data); + $allowed = self::allowedColumns(); + $setClauses = []; + $params = []; + + foreach ($allowed as $col) { + if (!array_key_exists($col, $data)) { + continue; + } + $setClauses[] = "{$col} = ?"; + $params[] = (string) ($data[$col] ?? ''); + } + + if ($setClauses === []) { + return true; + } + + $params[] = (string) $tenantId; return (bool) DB::update( - 'UPDATE helpdesk_tenant_settings SET override_enabled = ?, auth_mode = ?, odata_base_url = ?, company_name = ?, basic_user = ?, basic_password_enc = ?, oauth_tenant_id = ?, oauth_client_id = ?, oauth_client_secret_enc = ?, oauth_token_endpoint = ?, system_recommendations_config = ?, controlling_risk_config = ? WHERE tenant_id = ?', - $row['override_enabled'], - $row['auth_mode'], - $row['odata_base_url'], - $row['company_name'], - $row['basic_user'], - $row['basic_password_enc'], - $row['oauth_tenant_id'], - $row['oauth_client_id'], - $row['oauth_client_secret_enc'], - $row['oauth_token_endpoint'], - $row['system_recommendations_config'], - $row['controlling_risk_config'], - (string) $tenantId + 'UPDATE helpdesk_tenant_settings SET ' . implode(', ', $setClauses) . ' WHERE tenant_id = ?', + ...$params ); } /** - * Build a complete row array from partial data, using empty strings as defaults. + * Build a complete row for INSERT, using empty strings for missing columns. * * @param array $data * @return array */ private function buildRow(array $data): array { - $columns = [ + $row = []; + foreach (self::allowedColumns() as $col) { + $row[$col] = (string) ($data[$col] ?? ''); + } + + return $row; + } + + /** + * @return array + */ + private static function allowedColumns(): array + { + return [ 'override_enabled', 'auth_mode', 'odata_base_url', @@ -135,12 +156,5 @@ class HelpdeskTenantSettingsRepository 'system_recommendations_config', 'controlling_risk_config', ]; - - $row = []; - foreach ($columns as $col) { - $row[$col] = (string) ($data[$col] ?? ''); - } - - return $row; } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTokenRepository.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTokenRepository.php index 7c90a7a..3f43f68 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTokenRepository.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTokenRepository.php @@ -32,11 +32,13 @@ class HelpdeskTokenRepository (string) $tenantId ); - if (!is_array($result) || !isset($result['access_token_encrypted'])) { + // MintyPHP wraps selectOne results in ['table_name' => [...]] + $row = is_array($result) ? ($result['helpdesk_oauth_token_cache'] ?? $result) : null; + if (!is_array($row) || !isset($row['access_token_encrypted'])) { return null; } - $encrypted = trim((string) $result['access_token_encrypted']); + $encrypted = trim((string) $row['access_token_encrypted']); if ($encrypted === '') { return null; } @@ -49,13 +51,28 @@ class HelpdeskTokenRepository } /** - * Delete all cached tokens (used when BC config changes to prevent stale credentials). + * Delete all cached tokens (used when global BC config changes). */ public function deleteAll(): bool { return (bool) DB::delete('DELETE FROM helpdesk_oauth_token_cache WHERE 1=1'); } + /** + * Delete cached tokens for a specific tenant (used when tenant config changes). + */ + public function deleteByTenantId(int $tenantId): bool + { + if ($tenantId <= 0) { + return false; + } + + return (bool) DB::delete( + 'DELETE FROM helpdesk_oauth_token_cache WHERE tenant_id = ?', + (string) $tenantId + ); + } + /** * Store a new token in the cache (encrypted, tenant-scoped). */ diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/RiskRadarService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/RiskRadarService.php index dd135d6..baa8dfc 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/RiskRadarService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/RiskRadarService.php @@ -337,24 +337,6 @@ final class RiskRadarService return ($days * 86400) + ($hours * 3600) + ($minutes * 60) + $seconds; } - /** - * @param array $values - */ - private static function median(array $values): ?float - { - if ($values === []) { - return null; - } - - sort($values); - $count = count($values); - $mid = intdiv($count, 2); - - return $count % 2 === 0 - ? round(($values[$mid - 1] + $values[$mid]) / 2, 1) - : (float) $values[$mid]; - } - private static function parseTimestamp(string $value): ?int { $value = trim($value); diff --git a/modules/helpdesk/pages/helpdesk/debitor-tickets-data().php b/modules/helpdesk/pages/helpdesk/debitor-tickets-data().php index b90c5c3..6818b92 100644 --- a/modules/helpdesk/pages/helpdesk/debitor-tickets-data().php +++ b/modules/helpdesk/pages/helpdesk/debitor-tickets-data().php @@ -200,8 +200,8 @@ $rows = array_slice($filtered, $offset, $limit); // --- Row preparation --- $stateVariantMap = [ - 'Offen' => 'primary', - 'Open' => 'primary', + 'Offen' => 'warning', + 'Open' => 'warning', 'In Bearbeitung' => 'warning', 'In Progress' => 'warning', 'Erledigt' => 'success', diff --git a/modules/helpdesk/pages/helpdesk/risk-radar-data().php b/modules/helpdesk/pages/helpdesk/risk-radar-data().php index 5b2b01e..9e93eaa 100644 --- a/modules/helpdesk/pages/helpdesk/risk-radar-data().php +++ b/modules/helpdesk/pages/helpdesk/risk-radar-data().php @@ -53,7 +53,7 @@ if ($refreshRequested) { $gateway = app(BcODataGateway::class); try { - $ticketData = $gateway->getTicketsForRiskRadar(); + $ticketData = $gateway->getTicketsForRiskRadar($periodDays); } catch (\Throwable) { Router::json(['ok' => false, 'error' => 'Failed to load ticket data']); diff --git a/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml b/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml index 1b6215d..6e28e79 100644 --- a/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml @@ -25,7 +25,6 @@ $periodLabels = [ data-label-net-flow="" data-label-open-pressure="" data-label-inactivity="" - data-label-resolution="" data-label-trend="" data-label-why="" data-label-open="" @@ -66,6 +65,22 @@ $periodLabels = [ +
+
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+
diff --git a/modules/helpdesk/pages/helpdesk/settings/index().php b/modules/helpdesk/pages/helpdesk/settings/index().php index 05d318e..83a8aa0 100644 --- a/modules/helpdesk/pages/helpdesk/settings/index().php +++ b/modules/helpdesk/pages/helpdesk/settings/index().php @@ -65,12 +65,12 @@ if ($request->isMethod('POST')) { if (!$errorBag->hasAny()) { // Handle secrets — only update if new value provided $tenantBasicPassword = trim((string) ($post['tenant_basic_password'] ?? '')); - if ($tenantBasicPassword !== '' && $tenantBasicPassword !== '********') { + if ($tenantBasicPassword !== '') { $tenantData['basic_password'] = $tenantBasicPassword; } $tenantOauthSecret = trim((string) ($post['tenant_oauth_client_secret'] ?? '')); - if ($tenantOauthSecret !== '' && $tenantOauthSecret !== '********') { + if ($tenantOauthSecret !== '') { $tenantData['oauth_client_secret'] = $tenantOauthSecret; } @@ -80,7 +80,7 @@ if ($request->isMethod('POST')) { $saved = false; } if ($saved) { - $tokenRepository->deleteAll(); + $tokenRepository->deleteByTenantId($currentTenantId); Flash::success(t('Tenant settings saved'), 'helpdesk/settings', 'tenant_settings_saved'); } else { Flash::error(t('Failed to save tenant settings'), 'helpdesk/settings', 'tenant_settings_error'); @@ -125,14 +125,13 @@ if ($request->isMethod('POST')) { } $settingsGateway->setBasicUser($basicUser !== '' ? $basicUser : null); - // Only update password if a new value was provided (not the placeholder) - if ($basicPassword !== '' && $basicPassword !== '********') { + if ($basicPassword !== '') { $settingsGateway->setBasicPassword($basicPassword); } $settingsGateway->setOAuthTenantId($oauthTenantId !== '' ? $oauthTenantId : null); $settingsGateway->setOAuthClientId($oauthClientId !== '' ? $oauthClientId : null); - if ($oauthClientSecret !== '' && $oauthClientSecret !== '********') { + if ($oauthClientSecret !== '') { $settingsGateway->setOAuthClientSecret($oauthClientSecret); } if ($oauthTokenEndpoint !== '') { diff --git a/modules/helpdesk/pages/helpdesk/settings/index(default).phtml b/modules/helpdesk/pages/helpdesk/settings/index(default).phtml index 1bf217f..6843488 100644 --- a/modules/helpdesk/pages/helpdesk/settings/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/settings/index(default).phtml @@ -125,20 +125,26 @@ $tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2; -
+
- - - + 0): ?> - + +
-
-
+ +
+ 0 && $tenantOverrideEnabled): ?> +
+

+
+ + +
- +
-
+
+ + + +
+ + +
hidden> + + +
+ +
hidden> + + + + +
+
+
+ +
@@ -186,113 +247,110 @@ $tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
-
-
+ + 0): ?> +
+
- +
-
-
hiddenopen> - - - -
- +
hidden> +
+ + + +
+ + +
+
- +
+ + + +
+ + +
hidden> + + +
+ +
hidden> + + + + +
+
+
+
+ + +
+

-
- -
hiddenopen> - - - -
- - - - - - - -
-
+
+ +
@@ -426,120 +484,6 @@ $tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
- - 0): ?> -
-
- - - -
- -
-
- -
hidden> -
- - - -
- - - -
-
- -
- - - -
- - -
hidden> - - -
- -
hidden> - - - - -
-
-
-
- - -
-

-
- -
-
diff --git a/modules/helpdesk/pages/helpdesk/team/index().php b/modules/helpdesk/pages/helpdesk/team/index().php index e234727..565dcd4 100644 --- a/modules/helpdesk/pages/helpdesk/team/index().php +++ b/modules/helpdesk/pages/helpdesk/team/index().php @@ -7,10 +7,10 @@ use MintyPHP\Support\Guard; Guard::requireLogin(); Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_TEAM_WORKLOAD); -Buffer::set('title', t('Team dashboard')); +Buffer::set('title', t('Team workload')); Buffer::set('style_groups', json_encode(['helpdesk'])); $breadcrumbs = [ ['label' => t('Home'), 'path' => 'admin'], ['label' => t('Helpdesk'), 'path' => 'helpdesk'], - ['label' => t('Team dashboard')], + ['label' => t('Team workload')], ]; diff --git a/modules/helpdesk/pages/helpdesk/team/index(default).phtml b/modules/helpdesk/pages/helpdesk/team/index(default).phtml index cff4aaa..253fcba 100644 --- a/modules/helpdesk/pages/helpdesk/team/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/team/index(default).phtml @@ -39,7 +39,7 @@ $periodLabels = [
t('Team dashboard'), + 'title' => t('Team workload'), 'actions' => [ [ 'label' => t('Refresh data'), diff --git a/modules/helpdesk/templates/aside-helpdesk-panel.phtml b/modules/helpdesk/templates/aside-helpdesk-panel.phtml index 7dc78a2..c8a3733 100644 --- a/modules/helpdesk/templates/aside-helpdesk-panel.phtml +++ b/modules/helpdesk/templates/aside-helpdesk-panel.phtml @@ -22,7 +22,7 @@ $helpdeskNavItems = [ 'visible' => true, ], [ - 'label' => t('Team dashboard'), + 'label' => t('Team workload'), 'path' => 'helpdesk/team', 'active' => $teamActive, 'visible' => $canViewTeam, diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index 323fa08..4328f0e 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -529,6 +529,49 @@ margin-bottom: calc(var(--app-spacing) * 0.75); } + .helpdesk-risk-legend { + width: 100%; + } + + .helpdesk-risk-legend-list { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 0.75); + margin: 0; + padding: 0; + } + + .helpdesk-risk-legend-item { + display: inline-flex; + align-items: center; + gap: calc(var(--app-spacing) * 0.25); + font-size: var(--text-sm, 0.875rem); + } + + .helpdesk-risk-legend-dot { + width: 0.65rem; + height: 0.65rem; + border-radius: 999px; + flex: 0 0 auto; + border: 1px solid transparent; + } + + .helpdesk-risk-legend-dot.helpdesk-risk-level-high { + background: var(--app-danger, #dc3545); + border-color: color-mix(in srgb, var(--app-danger, #dc3545) 70%, black); + } + + .helpdesk-risk-legend-dot.helpdesk-risk-level-medium { + background: var(--app-warning, #f59e0b); + border-color: color-mix(in srgb, var(--app-warning, #f59e0b) 70%, black); + } + + .helpdesk-risk-legend-dot.helpdesk-risk-level-low { + background: var(--app-success, #198754); + border-color: color-mix(in srgb, var(--app-success, #198754) 70%, black); + } + .helpdesk-risk-radar-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); @@ -542,7 +585,9 @@ border-radius: var(--app-radius, 0.375rem); padding: calc(var(--app-spacing) * 0.65) calc(var(--app-spacing) * 0.75); margin-bottom: 0; - transition: border-color 0.15s ease, box-shadow 0.15s ease; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; cursor: pointer; } @@ -582,21 +627,23 @@ .helpdesk-risk-card-header { display: flex; - align-items: center; + align-items: self-start; justify-content: space-between; gap: calc(var(--app-spacing) * 0.6); } .helpdesk-risk-card-score { font-variant-numeric: tabular-nums; - font-weight: 600; font-size: var(--text-xs, 0.75rem); flex-shrink: 0; line-height: 1; - padding: 0.2em 0.55em; border-radius: 999px; display: inline-flex; align-items: center; + width: 3.5ch; + height: 3.5ch; + text-align: center; + justify-content: center; } .helpdesk-risk-card-score.helpdesk-risk-level-high { @@ -606,12 +653,20 @@ .helpdesk-risk-card-score.helpdesk-risk-level-medium { color: color-mix(in srgb, var(--app-warning, #f59e0b) 85%, black); - background: color-mix(in srgb, var(--app-warning, #f59e0b) 12%, transparent); + background: color-mix( + in srgb, + var(--app-warning, #f59e0b) 12%, + transparent + ); } .helpdesk-risk-card-score.helpdesk-risk-level-low { color: var(--app-success, #198754); - background: color-mix(in srgb, var(--app-success, #198754) 10%, transparent); + background: color-mix( + in srgb, + var(--app-success, #198754) 10%, + transparent + ); } .helpdesk-risk-card-info { @@ -640,46 +695,6 @@ color: var(--app-muted-color); } - .helpdesk-risk-card-driver-bar { - flex: 1; - height: 4px; - border-radius: 999px; - background: color-mix(in srgb, var(--app-muted-border-color) 50%, transparent); - } - - .helpdesk-risk-card-driver-fill { - height: 100%; - border-radius: 999px; - background: var(--app-primary, #0d6efd); - transition: width 0.3s ease; - } - - .helpdesk-risk-detail-dimensions { - display: grid; - gap: calc(var(--app-spacing) * 0.4); - margin-bottom: calc(var(--app-spacing) * 0.75); - } - - .helpdesk-risk-detail-dim { - display: flex; - align-items: center; - gap: calc(var(--app-spacing) * 0.5); - } - - .helpdesk-risk-detail-dim-label { - min-width: 10ch; - font-size: var(--text-sm, 0.875rem); - font-weight: 500; - } - - .helpdesk-risk-detail-dim-value { - font-variant-numeric: tabular-nums; - font-size: var(--text-sm, 0.875rem); - color: var(--app-muted-color); - min-width: 5ch; - text-align: right; - } - .helpdesk-risk-detail-tickets { width: 100%; font-size: var(--text-sm, 0.875rem); @@ -696,12 +711,17 @@ color: var(--app-muted-color); padding: calc(var(--app-spacing) * 0.3) calc(var(--app-spacing) * 0.5); border-bottom: 1px solid var(--app-muted-border-color); - background: color-mix(in srgb, var(--app-muted-border-color) 20%, transparent); + background: color-mix( + in srgb, + var(--app-muted-border-color) 20%, + transparent + ); } .helpdesk-risk-detail-tickets td { padding: calc(var(--app-spacing) * 0.3) calc(var(--app-spacing) * 0.5); - border-bottom: 1px solid color-mix(in srgb, var(--app-muted-border-color) 40%, transparent); + border-bottom: 1px solid + color-mix(in srgb, var(--app-muted-border-color) 40%, transparent); } .helpdesk-risk-detail-tickets tbody tr:last-child td { @@ -1559,7 +1579,6 @@ max-height: none; } - /* --- Controlling dashboard --- */ /* Segment control — radio + label (no button CSS bleed) */ diff --git a/modules/helpdesk/web/js/helpdesk-risk-radar.js b/modules/helpdesk/web/js/helpdesk-risk-radar.js index 7e04408..6e3998a 100644 --- a/modules/helpdesk/web/js/helpdesk-risk-radar.js +++ b/modules/helpdesk/web/js/helpdesk-risk-radar.js @@ -55,7 +55,6 @@ if (container) { 'open_pressure': d('labelOpenPressure', 'Open pressure'), 'trend': d('labelTrend', 'Trend'), 'sla_overdue': d('labelSla', 'SLA overdue'), - 'resolution': d('labelResolution', 'Resolution time'), 'inactivity': d('labelInactivity', 'Inactivity'), }; return map[id] || id; diff --git a/tests/Architecture/AsideNavigationAdminContractTest.php b/tests/Architecture/AsideNavigationAdminContractTest.php index 74d5674..3c775bf 100644 --- a/tests/Architecture/AsideNavigationAdminContractTest.php +++ b/tests/Architecture/AsideNavigationAdminContractTest.php @@ -19,8 +19,9 @@ final class AsideNavigationAdminContractTest extends TestCase $this->assertStringContainsString("'key' => 'admin-organization'", $content); $this->assertStringContainsString("'key' => 'admin-roles-permissions'", $content); $this->assertStringContainsString("'key' => 'admin-automation'", $content); - $this->assertStringContainsString("'key' => 'admin-monitoring'", $content); + $this->assertStringNotContainsString("'key' => 'admin-monitoring'", $content); $this->assertStringContainsString("'key' => 'admin-logs'", $content); $this->assertStringContainsString("'key' => 'admin-system'", $content); + $this->assertStringContainsString("'path' => 'admin/stats'", $content); } } diff --git a/tests/Architecture/FrontendRuntimeComponentContractTest.php b/tests/Architecture/FrontendRuntimeComponentContractTest.php index 2ffba1c..37768ac 100644 --- a/tests/Architecture/FrontendRuntimeComponentContractTest.php +++ b/tests/Architecture/FrontendRuntimeComponentContractTest.php @@ -29,6 +29,8 @@ class FrontendRuntimeComponentContractTest extends TestCase $this->assertStringContainsString('destroy: () => {', $sidebar); $this->assertStringContainsString("cleanupFns.push(() => document.removeEventListener('keydown', onShortcut));", $sidebar); + $this->assertStringContainsString("cleanupFns.push(() => mobileQuery.removeEventListener('change', onViewportChange));", $sidebar); + $this->assertStringContainsString("cleanupFns.push(() => document.removeEventListener('keydown', onEscDrawer));", $sidebar); $this->assertStringContainsString('destroy: () => {', $aside); $this->assertStringContainsString("cleanupFns.push(() => document.removeEventListener('keydown', onKeyDown));", $aside);