feat(helpdesk): refactor multi-tenant BC settings and risk radar improvements

Restructure helpdesk tenant settings into per-connection config with
improved token handling. Update risk radar data endpoint and UI.
Clean up ImageUploadTrait and update architecture contract tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 22:22:17 +02:00
parent 1582844b01
commit cacd1fb6d1
20 changed files with 439 additions and 396 deletions

View File

@@ -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;
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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<int, array<string, mixed>>, 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<int, array<string, mixed>>, 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;
}

View File

@@ -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<int, array<string, mixed>|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<string, mixed>|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;
}
}

View File

@@ -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<string, mixed> $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<string, mixed> $data
* @return array<string, string>
*/
private function buildRow(array $data): array
{
$columns = [
$row = [];
foreach (self::allowedColumns() as $col) {
$row[$col] = (string) ($data[$col] ?? '');
}
return $row;
}
/**
* @return array<int, string>
*/
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;
}
}

View File

@@ -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).
*/

View File

@@ -337,24 +337,6 @@ final class RiskRadarService
return ($days * 86400) + ($hours * 3600) + ($minutes * 60) + $seconds;
}
/**
* @param array<int, int> $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);

View File

@@ -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',

View File

@@ -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']);

View File

@@ -25,7 +25,6 @@ $periodLabels = [
data-label-net-flow="<?php e(t('Net flow')); ?>"
data-label-open-pressure="<?php e(t('Open pressure')); ?>"
data-label-inactivity="<?php e(t('Inactivity')); ?>"
data-label-resolution="<?php e(t('Resolution time')); ?>"
data-label-trend="<?php e(t('Trend')); ?>"
data-label-why="<?php e(t('Why at risk')); ?>"
data-label-open="<?php e(t('open')); ?>"
@@ -66,6 +65,22 @@ $periodLabels = [
<label for="radar-period-<?php e($p); ?>"><?php e($periodLabels[$p]); ?></label>
<?php endforeach; ?>
</div>
<div class="helpdesk-risk-legend">
<ul class="helpdesk-risk-legend-list" aria-label="<?php e(t('Risk score')); ?>">
<li class="helpdesk-risk-legend-item">
<span class="helpdesk-risk-legend-dot helpdesk-risk-level-high" aria-hidden="true"></span>
<span><?php e(t('High risk (55-100)')); ?></span>
</li>
<li class="helpdesk-risk-legend-item">
<span class="helpdesk-risk-legend-dot helpdesk-risk-level-medium" aria-hidden="true"></span>
<span><?php e(t('Medium risk (25-54)')); ?></span>
</li>
<li class="helpdesk-risk-legend-item">
<span class="helpdesk-risk-legend-dot helpdesk-risk-level-low" aria-hidden="true"></span>
<span><?php e(t('Low risk (0-24)')); ?></span>
</li>
</ul>
</div>
</div>
<div id="radar-loading">

View File

@@ -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 !== '') {

View File

@@ -125,20 +125,26 @@ $tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
<?php Session::getCsrfInput(); ?>
<input type="hidden" name="save_target" value="global" id="helpdesk-settings-save-target">
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="helpdesk-settings-tabs-v2">
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="helpdesk-settings-tabs-v3">
<div class="app-tabs-nav">
<button type="button" data-tab="connection" data-tab-default><?php e(t('Connection')); ?></button>
<button type="button" data-tab="auth"><?php e(t('Authentication')); ?></button>
<button type="button" data-tab="automation"><?php e(t('Automation')); ?></button>
<button type="button" data-tab="global" data-tab-default><?php e(t('General connection')); ?></button>
<?php if ($currentTenantId > 0): ?>
<button type="button" data-tab="tenant"><?php e(t('Tenant override')); ?></button>
<button type="button" data-tab="tenant"><?php e(t('Tenant connection')); ?></button>
<?php endif; ?>
<button type="button" data-tab="automation"><?php e(t('Automation')); ?></button>
</div>
<div data-tab-panel="connection" class="helpdesk-settings-panel">
<details class="app-details-card" name="helpdesk-odata" data-details-key="helpdesk-settings-odata" open>
<!-- ═══════════════ GLOBAL TAB ═══════════════ -->
<div data-tab-panel="global" class="helpdesk-settings-panel">
<?php if ($currentTenantId > 0 && $tenantOverrideEnabled): ?>
<div class="notice" data-variant="info" role="status">
<p><?php e(t('The current tenant uses its own configuration. Changes here only affect tenants without a tenant-specific connection.')); ?></p>
</div>
<?php endif; ?>
<details class="app-details-card" name="helpdesk-global-connection" data-details-key="helpdesk-settings-global-connection" open>
<summary>
<span class="app-details-card-summary-title"><?php e(t('OData connection')); ?></span>
<span class="app-details-card-summary-title"><?php e(t('Connection')); ?></span>
</summary>
<div class="app-details-card-container">
<label class="app-field" for="odata_base_url">
@@ -167,7 +173,62 @@ $tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
</div>
</details>
<details class="app-details-card" name="helpdesk-connection-test" data-details-key="helpdesk-settings-connection-test" open>
<details class="app-details-card" name="helpdesk-global-auth" data-details-key="helpdesk-settings-global-auth" open>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Authentication')); ?></span>
</summary>
<div class="app-details-card-container">
<label class="app-field" for="auth_mode">
<span><?php e(t('Auth mode')); ?></span>
<select id="auth_mode" name="auth_mode">
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_BASIC); ?>" <?php if (!$isOAuth2): ?>selected<?php endif; ?>>
<?php e(t('Basic Auth')); ?>
</option>
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2); ?>" <?php if ($isOAuth2): ?>selected<?php endif; ?>>
<?php e(t('OAuth2 (client credentials)')); ?>
</option>
</select>
</label>
<div id="helpdesk-basic-auth-fields" class="helpdesk-settings-rule-grid" <?php if ($isOAuth2): ?>hidden<?php endif; ?>>
<label class="app-field" for="basic_user">
<span><?php e(t('Username')); ?></span>
<input type="text" id="basic_user" name="basic_user" value="<?php e($basicUser); ?>" autocomplete="off">
</label>
<label class="app-field" for="basic_password">
<span><?php e(t('Password')); ?></span>
<input type="password" id="basic_password" name="basic_password" value="" placeholder="<?php if ($hasBasicPassword): ?><?php e(t('Saved')); ?><?php endif; ?>" autocomplete="new-password">
<?php if ($hasBasicPassword): ?>
<small><?php e(t('Leave empty to keep current password')); ?></small>
<?php endif; ?>
</label>
</div>
<div id="helpdesk-oauth2-fields" class="helpdesk-settings-rule-grid" <?php if (!$isOAuth2): ?>hidden<?php endif; ?>>
<label class="app-field" for="oauth_tenant_id">
<span><?php e(t('Azure Tenant ID')); ?></span>
<input type="text" id="oauth_tenant_id" name="oauth_tenant_id" value="<?php e($oauthTenantId); ?>" autocomplete="off">
</label>
<label class="app-field" for="oauth_client_id">
<span><?php e(t('Client ID')); ?></span>
<input type="text" id="oauth_client_id" name="oauth_client_id" value="<?php e($oauthClientId); ?>" autocomplete="off">
</label>
<label class="app-field" for="oauth_client_secret">
<span><?php e(t('Client Secret')); ?></span>
<input type="password" id="oauth_client_secret" name="oauth_client_secret" value="" placeholder="<?php if ($hasOAuthClientSecret): ?><?php e(t('Saved')); ?><?php endif; ?>" autocomplete="new-password">
<?php if ($hasOAuthClientSecret): ?>
<small><?php e(t('Leave empty to keep current password')); ?></small>
<?php endif; ?>
</label>
<label class="app-field" for="oauth_token_endpoint">
<span><?php e(t('Token endpoint URL')); ?></span>
<input type="url" id="oauth_token_endpoint" name="oauth_token_endpoint" value="<?php e($oauthTokenEndpoint); ?>" placeholder="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token">
</label>
</div>
</div>
</details>
<details class="app-details-card" name="helpdesk-global-test" data-details-key="helpdesk-settings-global-test" open>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Connection test')); ?></span>
</summary>
@@ -186,113 +247,110 @@ $tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
</details>
</div>
<div data-tab-panel="auth" class="helpdesk-settings-panel">
<details class="app-details-card" name="helpdesk-auth-mode" data-details-key="helpdesk-settings-auth-mode" open>
<!-- ═══════════════ TENANT TAB ═══════════════ -->
<?php if ($currentTenantId > 0): ?>
<div data-tab-panel="tenant" class="helpdesk-settings-panel">
<details class="app-details-card" name="helpdesk-tenant-override" data-details-key="helpdesk-settings-tenant-override" open>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Authentication')); ?></span>
<span class="app-details-card-summary-title"><?php e(t('Configuration source')); ?></span>
</summary>
<div class="app-details-card-container">
<label class="app-field" for="auth_mode">
<span><?php e(t('Auth mode')); ?></span>
<select id="auth_mode" name="auth_mode">
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_BASIC); ?>" <?php if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC): ?>selected<?php endif; ?>>
<?php e(t('Basic Auth')); ?>
</option>
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2); ?>" <?php if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2): ?>selected<?php endif; ?>>
<?php e(t('OAuth2 (client credentials)')); ?>
</option>
</select>
<label class="helpdesk-settings-rule">
<input
type="checkbox"
role="switch"
name="override_enabled"
id="helpdesk-override-toggle"
<?php if ($tenantOverrideEnabled): ?>checked<?php endif; ?>
>
<span class="helpdesk-settings-rule-label"><?php e(t('Use tenant-specific configuration')); ?></span>
<small><?php e(t('When enabled, this tenant uses its own BC connection instead of the global configuration.')); ?></small>
</label>
</div>
</details>
<details id="helpdesk-basic-auth-fields" class="app-details-card" name="helpdesk-basic-auth" data-details-key="helpdesk-settings-basic-auth" <?php if ($isOAuth2): ?>hidden<?php else: ?>open<?php endif; ?>>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Basic Auth credentials')); ?></span>
</summary>
<div class="app-details-card-container helpdesk-settings-rule-grid">
<label class="app-field" for="basic_user">
<span><?php e(t('Username')); ?></span>
<input
type="text"
id="basic_user"
name="basic_user"
value="<?php e($basicUser); ?>"
autocomplete="off"
>
</label>
<div id="helpdesk-tenant-fields" <?php if (!$tenantOverrideEnabled): ?>hidden<?php endif; ?>>
<details class="app-details-card" name="helpdesk-tenant-connection" data-details-key="helpdesk-settings-tenant-connection" open>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Connection')); ?></span>
</summary>
<div class="app-details-card-container">
<label class="app-field" for="tenant_odata_base_url">
<span><?php e(t('OData Base URL')); ?></span>
<input type="url" id="tenant_odata_base_url" name="tenant_odata_base_url" value="<?php e(trim((string) ($tenantRow['odata_base_url'] ?? ''))); ?>" placeholder="https://bc.example.com:7048/BusinessCentral/ODataV4">
</label>
<label class="app-field" for="tenant_company_name">
<span><?php e(t('Company name')); ?></span>
<input type="text" id="tenant_company_name" name="tenant_company_name" value="<?php e(trim((string) ($tenantRow['company_name'] ?? ''))); ?>">
</label>
</div>
</details>
<label class="app-field" for="basic_password">
<span><?php e(t('Password')); ?></span>
<input
type="password"
id="basic_password"
name="basic_password"
value="<?php if ($hasBasicPassword): ?>********<?php endif; ?>"
autocomplete="new-password"
>
<?php if ($hasBasicPassword): ?>
<small><?php e(t('Leave unchanged to keep current password')); ?></small>
<?php endif; ?>
</label>
<details class="app-details-card" name="helpdesk-tenant-auth" data-details-key="helpdesk-settings-tenant-auth" open>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Authentication')); ?></span>
</summary>
<div class="app-details-card-container">
<label class="app-field" for="tenant_auth_mode">
<span><?php e(t('Auth mode')); ?></span>
<select id="tenant_auth_mode" name="tenant_auth_mode">
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_BASIC); ?>" <?php if (!$tenantIsOAuth2): ?>selected<?php endif; ?>>
<?php e(t('Basic Auth')); ?>
</option>
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2); ?>" <?php if ($tenantIsOAuth2): ?>selected<?php endif; ?>>
<?php e(t('OAuth2 (client credentials)')); ?>
</option>
</select>
</label>
<div id="helpdesk-tenant-basic-auth-fields" class="helpdesk-settings-rule-grid" <?php if ($tenantIsOAuth2): ?>hidden<?php endif; ?>>
<label class="app-field" for="tenant_basic_user">
<span><?php e(t('Username')); ?></span>
<input type="text" id="tenant_basic_user" name="tenant_basic_user" value="<?php e(trim((string) ($tenantRow['basic_user'] ?? ''))); ?>" autocomplete="off">
</label>
<label class="app-field" for="tenant_basic_password">
<span><?php e(t('Password')); ?></span>
<input type="password" id="tenant_basic_password" name="tenant_basic_password" value="" placeholder="<?php if ($tenantHasBasicPassword): ?><?php e(t('Saved')); ?><?php endif; ?>" autocomplete="new-password">
<?php if ($tenantHasBasicPassword): ?>
<small><?php e(t('Leave empty to keep current password')); ?></small>
<?php endif; ?>
</label>
</div>
<div id="helpdesk-tenant-oauth2-fields" class="helpdesk-settings-rule-grid" <?php if (!$tenantIsOAuth2): ?>hidden<?php endif; ?>>
<label class="app-field" for="tenant_oauth_tenant_id">
<span><?php e(t('Azure Tenant ID')); ?></span>
<input type="text" id="tenant_oauth_tenant_id" name="tenant_oauth_tenant_id" value="<?php e(trim((string) ($tenantRow['oauth_tenant_id'] ?? ''))); ?>" autocomplete="off">
</label>
<label class="app-field" for="tenant_oauth_client_id">
<span><?php e(t('Client ID')); ?></span>
<input type="text" id="tenant_oauth_client_id" name="tenant_oauth_client_id" value="<?php e(trim((string) ($tenantRow['oauth_client_id'] ?? ''))); ?>" autocomplete="off">
</label>
<label class="app-field" for="tenant_oauth_client_secret">
<span><?php e(t('Client Secret')); ?></span>
<input type="password" id="tenant_oauth_client_secret" name="tenant_oauth_client_secret" value="" placeholder="<?php if ($tenantHasOAuthSecret): ?><?php e(t('Saved')); ?><?php endif; ?>" autocomplete="new-password">
<?php if ($tenantHasOAuthSecret): ?>
<small><?php e(t('Leave empty to keep current password')); ?></small>
<?php endif; ?>
</label>
<label class="app-field" for="tenant_oauth_token_endpoint">
<span><?php e(t('Token endpoint URL')); ?></span>
<input type="url" id="tenant_oauth_token_endpoint" name="tenant_oauth_token_endpoint" value="<?php e(trim((string) ($tenantRow['oauth_token_endpoint'] ?? ''))); ?>" placeholder="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token">
</label>
</div>
</div>
</details>
</div>
<?php if (!$tenantOverrideEnabled): ?>
<div class="notice" data-variant="info" role="status">
<p><?php e(t('This tenant inherits the global configuration. Enable the toggle above to use tenant-specific settings.')); ?></p>
</div>
</details>
<details id="helpdesk-oauth2-fields" class="app-details-card" name="helpdesk-oauth2-auth" data-details-key="helpdesk-settings-oauth2-auth" <?php if (!$isOAuth2): ?>hidden<?php else: ?>open<?php endif; ?>>
<summary>
<span class="app-details-card-summary-title"><?php e(t('OAuth2 credentials')); ?></span>
</summary>
<div class="app-details-card-container helpdesk-settings-rule-grid">
<label class="app-field" for="oauth_tenant_id">
<span><?php e(t('Azure Tenant ID')); ?></span>
<input
type="text"
id="oauth_tenant_id"
name="oauth_tenant_id"
value="<?php e($oauthTenantId); ?>"
autocomplete="off"
>
</label>
<label class="app-field" for="oauth_client_id">
<span><?php e(t('Client ID')); ?></span>
<input
type="text"
id="oauth_client_id"
name="oauth_client_id"
value="<?php e($oauthClientId); ?>"
autocomplete="off"
>
</label>
<label class="app-field" for="oauth_client_secret">
<span><?php e(t('Client Secret')); ?></span>
<input
type="password"
id="oauth_client_secret"
name="oauth_client_secret"
value="<?php if ($hasOAuthClientSecret): ?>********<?php endif; ?>"
autocomplete="new-password"
>
<?php if ($hasOAuthClientSecret): ?>
<small><?php e(t('Leave unchanged to keep current password')); ?></small>
<?php endif; ?>
</label>
<label class="app-field" for="oauth_token_endpoint">
<span><?php e(t('Token endpoint URL')); ?></span>
<input
type="url"
id="oauth_token_endpoint"
name="oauth_token_endpoint"
value="<?php e($oauthTokenEndpoint); ?>"
placeholder="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
>
</label>
</div>
</details>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- ═══════════════ AUTOMATION TAB ═══════════════ -->
<div data-tab-panel="automation" class="helpdesk-settings-panel">
<details class="app-details-card" name="helpdesk-recommendations" data-details-key="helpdesk-settings-recommendations-v2" open>
<summary>
@@ -426,120 +484,6 @@ $tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
</div>
</details>
</div>
<?php if ($currentTenantId > 0): ?>
<div data-tab-panel="tenant" class="helpdesk-settings-panel">
<details class="app-details-card" name="helpdesk-tenant-config-source" data-details-key="helpdesk-settings-tenant-source" open>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Configuration source')); ?></span>
</summary>
<div class="app-details-card-container">
<label class="helpdesk-settings-rule">
<input
type="checkbox"
role="switch"
name="override_enabled"
id="helpdesk-override-toggle"
<?php if ($tenantOverrideEnabled): ?>checked<?php endif; ?>
>
<span class="helpdesk-settings-rule-label"><?php e(t('Use tenant-specific configuration')); ?></span>
<small><?php e(t('When enabled, this tenant uses its own BC connection instead of the global configuration.')); ?></small>
</label>
</div>
</details>
<div id="helpdesk-tenant-fields" <?php if (!$tenantOverrideEnabled): ?>hidden<?php endif; ?>>
<details class="app-details-card" name="helpdesk-tenant-connection" data-details-key="helpdesk-settings-tenant-connection" open>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Tenant connection')); ?></span>
</summary>
<div class="app-details-card-container">
<label class="app-field" for="tenant_odata_base_url">
<span><?php e(t('OData Base URL')); ?></span>
<input
type="url"
id="tenant_odata_base_url"
name="tenant_odata_base_url"
value="<?php e(trim((string) ($tenantRow['odata_base_url'] ?? ''))); ?>"
placeholder="https://bc.example.com:7048/BusinessCentral/ODataV4"
>
</label>
<label class="app-field" for="tenant_company_name">
<span><?php e(t('Company name')); ?></span>
<input
type="text"
id="tenant_company_name"
name="tenant_company_name"
value="<?php e(trim((string) ($tenantRow['company_name'] ?? ''))); ?>"
>
</label>
</div>
</details>
<details class="app-details-card" name="helpdesk-tenant-auth" data-details-key="helpdesk-settings-tenant-auth" open>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Tenant authentication')); ?></span>
</summary>
<div class="app-details-card-container">
<label class="app-field" for="tenant_auth_mode">
<span><?php e(t('Auth mode')); ?></span>
<select id="tenant_auth_mode" name="tenant_auth_mode">
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_BASIC); ?>" <?php if (!$tenantIsOAuth2): ?>selected<?php endif; ?>>
<?php e(t('Basic Auth')); ?>
</option>
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2); ?>" <?php if ($tenantIsOAuth2): ?>selected<?php endif; ?>>
<?php e(t('OAuth2 (client credentials)')); ?>
</option>
</select>
</label>
<div id="helpdesk-tenant-basic-auth-fields" class="helpdesk-settings-rule-grid" <?php if ($tenantIsOAuth2): ?>hidden<?php endif; ?>>
<label class="app-field" for="tenant_basic_user">
<span><?php e(t('Username')); ?></span>
<input type="text" id="tenant_basic_user" name="tenant_basic_user" value="<?php e(trim((string) ($tenantRow['basic_user'] ?? ''))); ?>" autocomplete="off">
</label>
<label class="app-field" for="tenant_basic_password">
<span><?php e(t('Password')); ?></span>
<input type="password" id="tenant_basic_password" name="tenant_basic_password" value="<?php if ($tenantHasBasicPassword): ?>********<?php endif; ?>" autocomplete="new-password">
<?php if ($tenantHasBasicPassword): ?>
<small><?php e(t('Leave unchanged to keep current password')); ?></small>
<?php endif; ?>
</label>
</div>
<div id="helpdesk-tenant-oauth2-fields" class="helpdesk-settings-rule-grid" <?php if (!$tenantIsOAuth2): ?>hidden<?php endif; ?>>
<label class="app-field" for="tenant_oauth_tenant_id">
<span><?php e(t('Azure Tenant ID')); ?></span>
<input type="text" id="tenant_oauth_tenant_id" name="tenant_oauth_tenant_id" value="<?php e(trim((string) ($tenantRow['oauth_tenant_id'] ?? ''))); ?>" autocomplete="off">
</label>
<label class="app-field" for="tenant_oauth_client_id">
<span><?php e(t('Client ID')); ?></span>
<input type="text" id="tenant_oauth_client_id" name="tenant_oauth_client_id" value="<?php e(trim((string) ($tenantRow['oauth_client_id'] ?? ''))); ?>" autocomplete="off">
</label>
<label class="app-field" for="tenant_oauth_client_secret">
<span><?php e(t('Client Secret')); ?></span>
<input type="password" id="tenant_oauth_client_secret" name="tenant_oauth_client_secret" value="<?php if ($tenantHasOAuthSecret): ?>********<?php endif; ?>" autocomplete="new-password">
<?php if ($tenantHasOAuthSecret): ?>
<small><?php e(t('Leave unchanged to keep current password')); ?></small>
<?php endif; ?>
</label>
<label class="app-field" for="tenant_oauth_token_endpoint">
<span><?php e(t('Token endpoint URL')); ?></span>
<input type="url" id="tenant_oauth_token_endpoint" name="tenant_oauth_token_endpoint" value="<?php e(trim((string) ($tenantRow['oauth_token_endpoint'] ?? ''))); ?>" placeholder="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token">
</label>
</div>
</div>
</details>
</div>
<?php if (!$tenantOverrideEnabled): ?>
<div class="notice" data-variant="info" role="status">
<p><?php e(t('This tenant inherits the global configuration. Enable the toggle above to use tenant-specific settings.')); ?></p>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</form>
</section>

View File

@@ -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')],
];

View File

@@ -39,7 +39,7 @@ $periodLabels = [
<section>
<?php
$titlebar = [
'title' => t('Team dashboard'),
'title' => t('Team workload'),
'actions' => [
[
'label' => t('Refresh data'),

View File

@@ -22,7 +22,7 @@ $helpdeskNavItems = [
'visible' => true,
],
[
'label' => t('Team dashboard'),
'label' => t('Team workload'),
'path' => 'helpdesk/team',
'active' => $teamActive,
'visible' => $canViewTeam,

View File

@@ -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) */

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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);