feat(helpdesk): add dashboards, communication feed, settings UI and fix routing

Add support/sales/controlling dashboards with KPIs, trend charts and
risk indicators. Add debitor communication timeline, contact filters,
system recommendations engine, and configurable controlling risk rules.

Rename search→index to fix query-string preservation on back navigation.
Remove fake escalation rate metric, add KPI info tooltips, and switch
trend chart colors to red (created) / green (closed) for clarity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 18:34:03 +02:00
parent e897cc2c56
commit aee9cb10f3
43 changed files with 7451 additions and 635 deletions

View File

@@ -22,12 +22,68 @@ class HelpdeskSettingsGateway
public const KEY_OAUTH_CLIENT_ID = 'helpdesk.bc_oauth_client_id';
public const KEY_OAUTH_CLIENT_SECRET_ENC = 'helpdesk.bc_oauth_client_secret_enc';
public const KEY_OAUTH_TOKEN_ENDPOINT = 'helpdesk.bc_oauth_token_endpoint';
public const KEY_SYSTEM_RECOMMENDATIONS_CONFIG = 'helpdesk.system_recommendations_config';
public const KEY_CONTROLLING_RISK_CONFIG = 'helpdesk.controlling_risk_config';
public const AUTH_MODE_BASIC = 'basic';
public const AUTH_MODE_OAUTH2 = 'oauth2';
public const DEFAULT_ODATA_BASE_URL = 'https://bc.icoreon.de:7048/BusinessCentral-NUP/ODataV4';
public const DEFAULT_COMPANY_NAME = 'breadcrumb mediasolutions GmbH';
private const DEFAULT_CONTROLLING_RISK_CONFIG = [
'version' => 1,
'rules' => [
'ticket_volume_increase' => [
'enabled' => true,
'threshold_percent' => 30,
],
'avg_resolution_high' => [
'enabled' => true,
'threshold_hours' => 72,
],
'open_aging' => [
'enabled' => true,
'threshold_hours' => 168,
],
'unassigned_ratio' => [
'enabled' => true,
'threshold_percent' => 20,
],
],
];
private const DEFAULT_SYSTEM_RECOMMENDATIONS_CONFIG = [
'version' => 1,
'max_items' => 5,
'rules' => [
'escalation_overdue' => [
'enabled' => true,
'priority' => 100,
'min_overdue_minutes' => 0,
],
'high_risk_aging' => [
'enabled' => true,
'priority' => 90,
'codes' => ['MAX', 'HOCH'],
'min_age_hours' => 8,
],
'unassigned_open' => [
'enabled' => true,
'priority' => 80,
'min_age_hours' => 2,
],
'stale_open' => [
'enabled' => true,
'priority' => 70,
'min_age_hours' => 48,
],
'customer_backlog' => [
'enabled' => true,
'priority' => 60,
'min_open_tickets' => 6,
],
],
];
public function __construct(
private readonly SettingsMetadataGateway $settingsMetadataGateway,
@@ -259,6 +315,209 @@ class HelpdeskSettingsGateway
);
}
/**
* @return array{
* config: array{
* version: int,
* max_items: int,
* rules: array{
* escalation_overdue: array{enabled: bool, priority: int, min_overdue_minutes: int},
* high_risk_aging: array{enabled: bool, priority: int, codes: array<int, string>, min_age_hours: int},
* unassigned_open: array{enabled: bool, priority: int, min_age_hours: int},
* stale_open: array{enabled: bool, priority: int, min_age_hours: int},
* customer_backlog: array{enabled: bool, priority: int, min_open_tickets: int}
* }
* },
* source: 'default'|'settings'|'fallback_invalid_json'
* }
*/
public function getSystemRecommendationsConfigEnvelope(): array
{
$rawValue = $this->settingsMetadataGateway->getValue(self::KEY_SYSTEM_RECOMMENDATIONS_CONFIG);
$rawValue = trim((string) ($rawValue ?? ''));
if ($rawValue === '') {
return [
'config' => self::DEFAULT_SYSTEM_RECOMMENDATIONS_CONFIG,
'source' => 'default',
];
}
$decoded = json_decode($rawValue, true);
if (!is_array($decoded)) {
return [
'config' => self::DEFAULT_SYSTEM_RECOMMENDATIONS_CONFIG,
'source' => 'fallback_invalid_json',
];
}
return [
'config' => self::normalizeSystemRecommendationsConfig($decoded),
'source' => 'settings',
];
}
/**
* @return array{
* version: int,
* max_items: int,
* rules: array{
* escalation_overdue: array{enabled: bool, priority: int, min_overdue_minutes: int},
* high_risk_aging: array{enabled: bool, priority: int, codes: array<int, string>, min_age_hours: int},
* unassigned_open: array{enabled: bool, priority: int, min_age_hours: int},
* stale_open: array{enabled: bool, priority: int, min_age_hours: int},
* customer_backlog: array{enabled: bool, priority: int, min_open_tickets: int}
* }
* }
*/
public function getSystemRecommendationsConfig(): array
{
$envelope = $this->getSystemRecommendationsConfigEnvelope();
return $envelope['config'];
}
/**
* @param array<string, mixed> $config
*/
public function setSystemRecommendationsConfig(array $config): bool
{
$normalized = self::normalizeSystemRecommendationsConfig($config);
$encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (!is_string($encoded) || $encoded === '') {
return false;
}
return $this->settingsMetadataGateway->set(
self::KEY_SYSTEM_RECOMMENDATIONS_CONFIG,
$encoded,
'helpdesk.system_recommendations_config'
);
}
/**
* @param array<string, mixed> $config
* @return array{
* version: int,
* max_items: int,
* rules: array{
* escalation_overdue: array{enabled: bool, priority: int, min_overdue_minutes: int},
* high_risk_aging: array{enabled: bool, priority: int, codes: array<int, string>, min_age_hours: int},
* unassigned_open: array{enabled: bool, priority: int, min_age_hours: int},
* stale_open: array{enabled: bool, priority: int, min_age_hours: int},
* customer_backlog: array{enabled: bool, priority: int, min_open_tickets: int}
* }
* }
*/
private static function normalizeSystemRecommendationsConfig(array $config): array
{
$defaults = self::DEFAULT_SYSTEM_RECOMMENDATIONS_CONFIG;
$rawRules = is_array($config['rules'] ?? null) ? $config['rules'] : [];
$escalationOverdue = is_array($rawRules['escalation_overdue'] ?? null) ? $rawRules['escalation_overdue'] : [];
$highRiskAging = is_array($rawRules['high_risk_aging'] ?? null) ? $rawRules['high_risk_aging'] : [];
$unassignedOpen = is_array($rawRules['unassigned_open'] ?? null) ? $rawRules['unassigned_open'] : [];
$staleOpen = is_array($rawRules['stale_open'] ?? null) ? $rawRules['stale_open'] : [];
$customerBacklog = is_array($rawRules['customer_backlog'] ?? null) ? $rawRules['customer_backlog'] : [];
return [
'version' => 1,
'max_items' => self::normalizeIntRange($config['max_items'] ?? $defaults['max_items'], 1, 20, (int) $defaults['max_items']),
'rules' => [
'escalation_overdue' => [
'enabled' => self::normalizeBool($escalationOverdue['enabled'] ?? $defaults['rules']['escalation_overdue']['enabled']),
'priority' => self::normalizeIntRange($escalationOverdue['priority'] ?? $defaults['rules']['escalation_overdue']['priority'], 1, 999, (int) $defaults['rules']['escalation_overdue']['priority']),
'min_overdue_minutes' => self::normalizeIntRange($escalationOverdue['min_overdue_minutes'] ?? $defaults['rules']['escalation_overdue']['min_overdue_minutes'], 0, 43200, (int) $defaults['rules']['escalation_overdue']['min_overdue_minutes']),
],
'high_risk_aging' => [
'enabled' => self::normalizeBool($highRiskAging['enabled'] ?? $defaults['rules']['high_risk_aging']['enabled']),
'priority' => self::normalizeIntRange($highRiskAging['priority'] ?? $defaults['rules']['high_risk_aging']['priority'], 1, 999, (int) $defaults['rules']['high_risk_aging']['priority']),
'codes' => self::normalizeCodeList($highRiskAging['codes'] ?? $defaults['rules']['high_risk_aging']['codes']),
'min_age_hours' => self::normalizeIntRange($highRiskAging['min_age_hours'] ?? $defaults['rules']['high_risk_aging']['min_age_hours'], 0, 720, (int) $defaults['rules']['high_risk_aging']['min_age_hours']),
],
'unassigned_open' => [
'enabled' => self::normalizeBool($unassignedOpen['enabled'] ?? $defaults['rules']['unassigned_open']['enabled']),
'priority' => self::normalizeIntRange($unassignedOpen['priority'] ?? $defaults['rules']['unassigned_open']['priority'], 1, 999, (int) $defaults['rules']['unassigned_open']['priority']),
'min_age_hours' => self::normalizeIntRange($unassignedOpen['min_age_hours'] ?? $defaults['rules']['unassigned_open']['min_age_hours'], 0, 720, (int) $defaults['rules']['unassigned_open']['min_age_hours']),
],
'stale_open' => [
'enabled' => self::normalizeBool($staleOpen['enabled'] ?? $defaults['rules']['stale_open']['enabled']),
'priority' => self::normalizeIntRange($staleOpen['priority'] ?? $defaults['rules']['stale_open']['priority'], 1, 999, (int) $defaults['rules']['stale_open']['priority']),
'min_age_hours' => self::normalizeIntRange($staleOpen['min_age_hours'] ?? $defaults['rules']['stale_open']['min_age_hours'], 0, 1440, (int) $defaults['rules']['stale_open']['min_age_hours']),
],
'customer_backlog' => [
'enabled' => self::normalizeBool($customerBacklog['enabled'] ?? $defaults['rules']['customer_backlog']['enabled']),
'priority' => self::normalizeIntRange($customerBacklog['priority'] ?? $defaults['rules']['customer_backlog']['priority'], 1, 999, (int) $defaults['rules']['customer_backlog']['priority']),
'min_open_tickets' => self::normalizeIntRange($customerBacklog['min_open_tickets'] ?? $defaults['rules']['customer_backlog']['min_open_tickets'], 1, 500, (int) $defaults['rules']['customer_backlog']['min_open_tickets']),
],
],
];
}
/**
* @param mixed $value
*/
private static function normalizeBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
return false;
}
}
return (bool) $value;
}
/**
* @param mixed $value
*/
private static function normalizeIntRange(mixed $value, int $min, int $max, int $default): int
{
if (!is_numeric($value)) {
return $default;
}
return max($min, min($max, (int) $value));
}
/**
* @param mixed $value
* @return array<int, string>
*/
private static function normalizeCodeList(mixed $value): array
{
$rawCodes = [];
if (is_array($value)) {
$rawCodes = $value;
} elseif (is_string($value)) {
$rawCodes = explode(',', $value);
}
$normalized = [];
foreach ($rawCodes as $code) {
$candidate = strtoupper(trim((string) $code));
if ($candidate === '') {
continue;
}
$normalized[] = $candidate;
}
$normalized = array_values(array_unique($normalized));
if ($normalized === []) {
return ['MAX', 'HOCH'];
}
return $normalized;
}
/**
* Build the full OData entity URL for a given entity set.
*/
@@ -307,4 +566,102 @@ class HelpdeskSettingsGateway
{
return $this->validateConfiguration() === [];
}
// --- Controlling risk configuration ---
/**
* @return array{
* config: array{version: int, rules: array<string, array<string, mixed>>},
* source: 'default'|'settings'|'fallback_invalid_json'
* }
*/
public function getControllingRiskConfigEnvelope(): array
{
$rawValue = $this->settingsMetadataGateway->getValue(self::KEY_CONTROLLING_RISK_CONFIG);
$rawValue = trim((string) ($rawValue ?? ''));
if ($rawValue === '') {
return [
'config' => self::DEFAULT_CONTROLLING_RISK_CONFIG,
'source' => 'default',
];
}
$decoded = json_decode($rawValue, true);
if (!is_array($decoded)) {
return [
'config' => self::DEFAULT_CONTROLLING_RISK_CONFIG,
'source' => 'fallback_invalid_json',
];
}
return [
'config' => self::normalizeControllingRiskConfig($decoded),
'source' => 'settings',
];
}
/**
* @return array{version: int, rules: array<string, array<string, mixed>>}
*/
public function getControllingRiskConfig(): array
{
$envelope = $this->getControllingRiskConfigEnvelope();
return $envelope['config'];
}
/**
* @param array<string, mixed> $config
*/
public function setControllingRiskConfig(array $config): bool
{
$normalized = self::normalizeControllingRiskConfig($config);
$encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (!is_string($encoded) || $encoded === '') {
return false;
}
return $this->settingsMetadataGateway->set(
self::KEY_CONTROLLING_RISK_CONFIG,
$encoded,
'helpdesk.controlling_risk_config'
);
}
/**
* @param array<string, mixed> $config
* @return array{version: int, rules: array<string, array<string, mixed>>}
*/
private static function normalizeControllingRiskConfig(array $config): array
{
$defaults = self::DEFAULT_CONTROLLING_RISK_CONFIG;
$rawRules = is_array($config['rules'] ?? null) ? $config['rules'] : [];
$ticketVolume = is_array($rawRules['ticket_volume_increase'] ?? null) ? $rawRules['ticket_volume_increase'] : [];
$avgResolution = is_array($rawRules['avg_resolution_high'] ?? null) ? $rawRules['avg_resolution_high'] : [];
$openAging = is_array($rawRules['open_aging'] ?? null) ? $rawRules['open_aging'] : [];
$unassigned = is_array($rawRules['unassigned_ratio'] ?? null) ? $rawRules['unassigned_ratio'] : [];
return [
'version' => 1,
'rules' => [
'ticket_volume_increase' => [
'enabled' => self::normalizeBool($ticketVolume['enabled'] ?? $defaults['rules']['ticket_volume_increase']['enabled']),
'threshold_percent' => self::normalizeIntRange($ticketVolume['threshold_percent'] ?? $defaults['rules']['ticket_volume_increase']['threshold_percent'], 5, 200, (int) $defaults['rules']['ticket_volume_increase']['threshold_percent']),
],
'avg_resolution_high' => [
'enabled' => self::normalizeBool($avgResolution['enabled'] ?? $defaults['rules']['avg_resolution_high']['enabled']),
'threshold_hours' => self::normalizeIntRange($avgResolution['threshold_hours'] ?? $defaults['rules']['avg_resolution_high']['threshold_hours'], 1, 720, (int) $defaults['rules']['avg_resolution_high']['threshold_hours']),
],
'open_aging' => [
'enabled' => self::normalizeBool($openAging['enabled'] ?? $defaults['rules']['open_aging']['enabled']),
'threshold_hours' => self::normalizeIntRange($openAging['threshold_hours'] ?? $defaults['rules']['open_aging']['threshold_hours'], 1, 2160, (int) $defaults['rules']['open_aging']['threshold_hours']),
],
'unassigned_ratio' => [
'enabled' => self::normalizeBool($unassigned['enabled'] ?? $defaults['rules']['unassigned_ratio']['enabled']),
'threshold_percent' => self::normalizeIntRange($unassigned['threshold_percent'] ?? $defaults['rules']['unassigned_ratio']['threshold_percent'], 1, 100, (int) $defaults['rules']['unassigned_ratio']['threshold_percent']),
],
],
];
}
}