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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')],
|
||||
];
|
||||
|
||||
@@ -39,7 +39,7 @@ $periodLabels = [
|
||||
<section>
|
||||
<?php
|
||||
$titlebar = [
|
||||
'title' => t('Team dashboard'),
|
||||
'title' => t('Team workload'),
|
||||
'actions' => [
|
||||
[
|
||||
'label' => t('Refresh data'),
|
||||
|
||||
@@ -22,7 +22,7 @@ $helpdeskNavItems = [
|
||||
'visible' => true,
|
||||
],
|
||||
[
|
||||
'label' => t('Team dashboard'),
|
||||
'label' => t('Team workload'),
|
||||
'path' => 'helpdesk/team',
|
||||
'active' => $teamActive,
|
||||
'visible' => $canViewTeam,
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user