diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index b369a2b..93d2542 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -338,5 +338,12 @@ "Tenant settings saved": "Mandanten-Einstellungen gespeichert", "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" + "Failed to save tenant settings": "Mandanten-Einstellungen konnten nicht gespeichert werden", + "Meetings": "Termine", + "Released": "Freigegeben", + "Pending": "Ausstehend", + "Upcoming meetings": "Kommende Termine", + "Past meetings": "Vergangene Termine", + "No meetings found for this customer.": "Keine Termine für diesen Kunden gefunden.", + "Could not load meetings.": "Termine konnten nicht geladen werden." } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index fe93ec6..e03a598 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -338,5 +338,12 @@ "Tenant settings saved": "Tenant settings saved", "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" + "Failed to save tenant settings": "Failed to save tenant settings", + "Meetings": "Meetings", + "Released": "Released", + "Pending": "Pending", + "Upcoming meetings": "Upcoming meetings", + "Past meetings": "Past meetings", + "No meetings found for this customer.": "No meetings found for this customer.", + "Could not load meetings.": "Could not load meetings." } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php index ae90e0e..a0277a3 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php @@ -21,6 +21,7 @@ class BcODataGateway public const ENTITY_TICKET_LOG = 'PBI_FP_TicketLog'; public const ENTITY_TICKET_LOG_LV = 'PBI_LV_SupportTicketLog'; public const ENTITY_CONTRACT_LINES = 'FS_Contract_Lines_Test'; + public const ENTITY_MEETINGS = 'FS_Debitor_Meetings'; private const CONNECT_TIMEOUT = 10; private const REQUEST_TIMEOUT = 30; @@ -800,6 +801,35 @@ class BcODataGateway return $this->extractODataValues($response); } + /** + * Get meetings/appointments for a customer. + * + * @return array> + */ + public function getMeetingsForCustomer(string $customerNo): array + { + $customerNo = trim($customerNo); + if ($customerNo === '') { + return []; + } + + $escaped = $this->escapeODataTrustedLiteral($customerNo); + $filter = "Customer_No eq '" . $escaped . "'"; + $select = 'Entry_No,Appointment_Date,Customer_No,Customer_Name,Salesperson_Code,Comment,Released_by,Release_Date'; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_MEETINGS) + . '?$filter=' . rawurlencode($filter) + . '&$top=200' + . '&$select=' . rawurlencode($select) + . '&$orderby=' . rawurlencode('Appointment_Date desc'); + + $response = $this->request('GET', $url); + if ($response === null) { + return []; + } + + return $this->extractODataValues($response); + } + /** * Get a single ticket by ticket number. * diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php index f185206..07e6998 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php @@ -77,6 +77,11 @@ final class DebitorCacheControl return 'module.helpdesk.debitor.v2.controlling-tickets.' . $tenantScope . '.' . trim($customerNo); } + public static function meetingsKey(string $tenantScope, string $customerNo): string + { + return 'module.helpdesk.debitor.v2.meetings.' . $tenantScope . '.' . trim($customerNo); + } + public static function escalationDefinitionsKey(string $tenantScope): string { return 'module.helpdesk.debitor.v1.escalation-definitions.' . $tenantScope; @@ -105,6 +110,7 @@ final class DebitorCacheControl self::contractsKey($tenantScope, $customerNo), self::contractLinesKey($tenantScope, $customerNo), self::controllingTicketsKey($tenantScope, $customerNo), + self::meetingsKey($tenantScope, $customerNo), // Legacy keys kept for hard refresh compatibility 'module.helpdesk.tickets_cache.' . $tenantScope . '.' . trim($customerNo), 'module.helpdesk.contacts_cache.v1.' . $tenantScope . '.' . trim($customerNo), diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php index 7fd7f05..9a8b5af 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php @@ -732,6 +732,125 @@ class DebitorDetailService * error?: string * } */ + /** + * Load meetings for a customer from BC. + * + * @return array{ok: bool, timeline?: array, kpis?: array, error?: string} + */ + public function loadMeetings(string $customerNo): array + { + $customerNo = trim($customerNo); + if ($customerNo === '') { + return ['ok' => false, 'error' => 'Missing customer number']; + } + + if (!$this->settingsGateway->isConfigured()) { + return ['ok' => false, 'error' => 'BC connection not configured']; + } + + try { + $meetings = $this->bcODataGateway->getMeetingsForCustomer($customerNo); + } catch (\Throwable $e) { + return ['ok' => false, 'error' => $e->getMessage()]; + } + + $result = self::buildMeetingsTimeline($meetings); + + return [ + 'ok' => true, + 'timeline' => $result['timeline'], + 'kpis' => $result['kpis'], + ]; + } + + /** + * Build meetings timeline from raw OData meeting records. + * + * @param array> $meetings + * @return array{timeline: array{upcoming: array, past: array}, kpis: array} + */ + public static function buildMeetingsTimeline(array $meetings): array + { + $today = date('Y-m-d'); + $upcoming = []; + $past = []; + $total = 0; + $lastMeetingDate = ''; + + foreach ($meetings as $meeting) { + $appointmentDate = trim((string) ($meeting['Appointment_Date'] ?? '')); + if ($appointmentDate === '' || str_starts_with($appointmentDate, '0001-01-01')) { + continue; + } + + $releaseDate = trim((string) ($meeting['Release_Date'] ?? '')); + $isReleased = $releaseDate !== '' && !str_starts_with($releaseDate, '0001-01-01'); + + $entry = [ + 'entry_no' => (int) ($meeting['Entry_No'] ?? 0), + 'appointment_date' => $appointmentDate, + 'customer_no' => trim((string) ($meeting['Customer_No'] ?? '')), + 'customer_name' => trim((string) ($meeting['Customer_Name'] ?? '')), + 'salesperson_code' => trim((string) ($meeting['Salesperson_Code'] ?? '')), + 'comment' => trim((string) ($meeting['Comment'] ?? '')), + 'released_by' => trim((string) ($meeting['Released_by'] ?? '')), + 'release_date' => $isReleased ? $releaseDate : null, + 'is_released' => $isReleased, + ]; + + $total++; + + if ($appointmentDate >= $today) { + $monthKey = substr($appointmentDate, 0, 7); + $upcoming[$monthKey][] = $entry; + } else { + $monthKey = substr($appointmentDate, 0, 7); + $past[$monthKey][] = $entry; + if ($lastMeetingDate === '' || $appointmentDate > $lastMeetingDate) { + $lastMeetingDate = $appointmentDate; + } + } + } + + // Sort upcoming ascending (nearest first), past descending (most recent first) + ksort($upcoming); + krsort($past); + + // Within each month group: upcoming asc, past desc + foreach ($upcoming as &$group) { + usort($group, static fn (array $a, array $b) => strcmp($a['appointment_date'], $b['appointment_date'])); + } + unset($group); + foreach ($past as &$group) { + usort($group, static fn (array $a, array $b) => strcmp($b['appointment_date'], $a['appointment_date'])); + } + unset($group); + + $daysSinceLast = 0; + if ($lastMeetingDate !== '') { + $diff = (new \DateTimeImmutable($today))->diff(new \DateTimeImmutable($lastMeetingDate)); + $daysSinceLast = (int) $diff->days; + } + + $upcomingCount = 0; + foreach ($upcoming as $group) { + $upcomingCount += count($group); + } + + return [ + 'timeline' => [ + 'upcoming' => $upcoming, + 'past' => $past, + ], + 'kpis' => [ + 'total' => $total, + 'upcoming_count' => $upcomingCount, + 'days_since_last' => $daysSinceLast, + 'last_meeting_date' => $lastMeetingDate !== '' ? $lastMeetingDate : null, + ], + ]; + } + public function loadSalesDashboard(string $customerNo, string $customerName): array { $customerNo = trim($customerNo); diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php index 40aaa4e..6c20413 100644 --- a/modules/helpdesk/module.php +++ b/modules/helpdesk/module.php @@ -31,6 +31,7 @@ return [ ['path' => 'helpdesk/ticket-file-data', 'target' => 'helpdesk/ticket-file-data'], ['path' => 'helpdesk/debitor-communication-data', 'target' => 'helpdesk/debitor-communication-data'], ['path' => 'helpdesk/debitor-sales-dashboard-data', 'target' => 'helpdesk/debitor-sales-dashboard-data'], + ['path' => 'helpdesk/debitor-meetings-data', 'target' => 'helpdesk/debitor-meetings-data'], ['path' => 'helpdesk/debitor-controlling-dashboard-data', 'target' => 'helpdesk/debitor-controlling-dashboard-data'], ['path' => 'helpdesk/team', 'target' => 'helpdesk/team'], ['path' => 'helpdesk/team-workload-data', 'target' => 'helpdesk/team-workload-data'], diff --git a/modules/helpdesk/pages/helpdesk/debitor(default).phtml b/modules/helpdesk/pages/helpdesk/debitor(default).phtml index ed7ad7e..180e419 100644 --- a/modules/helpdesk/pages/helpdesk/debitor(default).phtml +++ b/modules/helpdesk/pages/helpdesk/debitor(default).phtml @@ -35,6 +35,7 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null; data-communication-url="" data-file-download-url="" data-sales-dashboard-url="" + data-meetings-url="" data-controlling-dashboard-url="" >
@@ -201,18 +202,37 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
-
-

- -
-
+
+
+

+ +
+
+ +
+

+
+ +
+
+
+
+ +
+ + + +
+
@@ -469,6 +489,13 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null; 'ended' => t('ended'), 'No contract details available.' => t('No contract details available.'), 'Could not load sales dashboard.' => t('Could not load sales dashboard.'), + 'Meetings' => t('Meetings'), + 'Released' => t('Released'), + 'Pending' => t('Pending'), + 'Upcoming meetings' => t('Upcoming meetings'), + 'Past meetings' => t('Past meetings'), + 'No meetings found for this customer.' => t('No meetings found for this customer.'), + 'Could not load meetings.' => t('Could not load meetings.'), 'Quantity' => t('Quantity'), 'Unit price' => t('Unit price'), 'Line amount' => t('Line amount'), diff --git a/modules/helpdesk/pages/helpdesk/debitor-meetings-data().php b/modules/helpdesk/pages/helpdesk/debitor-meetings-data().php new file mode 100644 index 0000000..37250ea --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/debitor-meetings-data().php @@ -0,0 +1,72 @@ +method() !== 'GET') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + + return; +} + +$customerNo = trim((string) $request->query('customerNo', '')); +$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', '')); + +if ($customerNo === '') { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'missing_parameters']); + + return; +} + +$sessionStore = app(SessionStoreInterface::class); +$session = $sessionStore->all(); +$tenantScope = DebitorCacheControl::resolveTenantScope($session); +if ($refreshRequested) { + $sessionStore->remove(DebitorCacheControl::meetingsKey($tenantScope, $customerNo)); +} + +$cacheKey = DebitorCacheControl::meetingsKey($tenantScope, $customerNo); +$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS; +$cached = $sessionStore->get($cacheKey); +$cacheUsed = false; + +if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) { + $cacheUsed = true; + $payload = is_array($cached['payload'] ?? null) ? $cached['payload'] : ['ok' => false]; + $payload['meta'] = [ + 'cache_used' => true, + 'cache_bypassed' => false, + 'cache_refreshed' => false, + ]; + Router::json($payload); + + return; +} + +$service = app(DebitorDetailService::class); +$result = $service->loadMeetings($customerNo); + +if (($result['ok'] ?? false) === true) { + $sessionStore->set($cacheKey, [ + 'payload' => $result, + 'fetched_at' => time(), + ]); +} + +$result['meta'] = [ + 'cache_used' => $cacheUsed, + 'cache_bypassed' => $refreshRequested, + 'cache_refreshed' => $refreshRequested, +]; + +Router::json($result); diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorCacheControlTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorCacheControlTest.php index 5d4bd6a..617087e 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorCacheControlTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorCacheControlTest.php @@ -38,7 +38,7 @@ class DebitorCacheControlTest extends TestCase public function testInvalidateDebitorCachesRemovesDebitorAndTenantSharedKeys(): void { $sessionStore = $this->createMock(SessionStoreInterface::class); - $sessionStore->expects($this->exactly(13)) + $sessionStore->expects($this->exactly(14)) ->method('remove') ->with($this->callback(static function (string $key): bool { return str_contains($key, '.13.10259') diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php index b18930b..1bf5800 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php @@ -1245,4 +1245,134 @@ class DebitorDetailServiceTest extends TestCase $this->assertSame(2, $result['members'][0]['open_tickets']); $this->assertSame('Max Müller', $result['members'][0]['display_name']); } + + // --- buildMeetingsTimeline() --- + + public function testBuildMeetingsTimelineReturnsEmptyForNoMeetings(): void + { + $result = DebitorDetailService::buildMeetingsTimeline([]); + + $this->assertSame([], $result['timeline']['upcoming']); + $this->assertSame([], $result['timeline']['past']); + $this->assertSame(0, $result['kpis']['total']); + $this->assertSame(0, $result['kpis']['upcoming_count']); + $this->assertSame(0, $result['kpis']['days_since_last']); + $this->assertNull($result['kpis']['last_meeting_date']); + } + + public function testBuildMeetingsTimelineSkipsZeroDateEntries(): void + { + $meetings = [ + ['Entry_No' => 1, 'Appointment_Date' => '0001-01-01', 'Customer_No' => '10254', 'Comment' => 'Skip me'], + ['Entry_No' => 2, 'Appointment_Date' => '', 'Customer_No' => '10254', 'Comment' => 'Also skip'], + ]; + + $result = DebitorDetailService::buildMeetingsTimeline($meetings); + + $this->assertSame(0, $result['kpis']['total']); + } + + public function testBuildMeetingsTimelineSplitsUpcomingAndPast(): void + { + $today = date('Y-m-d'); + $futureDate = date('Y-m-d', strtotime('+30 days')); + $pastDate = date('Y-m-d', strtotime('-10 days')); + + $meetings = [ + ['Entry_No' => 1, 'Appointment_Date' => $futureDate, 'Customer_No' => '10254', 'Customer_Name' => 'Test', 'Salesperson_Code' => 'MER', 'Comment' => 'Future meeting', 'Released_by' => '', 'Release_Date' => '0001-01-01'], + ['Entry_No' => 2, 'Appointment_Date' => $pastDate, 'Customer_No' => '10254', 'Customer_Name' => 'Test', 'Salesperson_Code' => 'MER', 'Comment' => 'Past meeting', 'Released_by' => 'USER', 'Release_Date' => $pastDate], + ]; + + $result = DebitorDetailService::buildMeetingsTimeline($meetings); + + $this->assertSame(2, $result['kpis']['total']); + $this->assertSame(1, $result['kpis']['upcoming_count']); + $this->assertSame(10, $result['kpis']['days_since_last']); + $this->assertSame($pastDate, $result['kpis']['last_meeting_date']); + + $futureMonth = substr($futureDate, 0, 7); + $pastMonth = substr($pastDate, 0, 7); + + $this->assertArrayHasKey($futureMonth, $result['timeline']['upcoming']); + $this->assertArrayHasKey($pastMonth, $result['timeline']['past']); + + // Verify upcoming entry + $upcomingEntry = $result['timeline']['upcoming'][$futureMonth][0]; + $this->assertSame('Future meeting', $upcomingEntry['comment']); + $this->assertFalse($upcomingEntry['is_released']); + + // Verify past entry + $pastEntry = $result['timeline']['past'][$pastMonth][0]; + $this->assertSame('Past meeting', $pastEntry['comment']); + $this->assertTrue($pastEntry['is_released']); + $this->assertSame($pastDate, $pastEntry['release_date']); + } + + public function testBuildMeetingsTimelineGroupsByMonth(): void + { + $meetings = [ + ['Entry_No' => 1, 'Appointment_Date' => '2025-03-15', 'Customer_No' => '10254', 'Customer_Name' => 'A', 'Salesperson_Code' => 'MER', 'Comment' => 'A', 'Released_by' => '', 'Release_Date' => '0001-01-01'], + ['Entry_No' => 2, 'Appointment_Date' => '2025-03-20', 'Customer_No' => '10254', 'Customer_Name' => 'B', 'Salesperson_Code' => 'MER', 'Comment' => 'B', 'Released_by' => '', 'Release_Date' => '0001-01-01'], + ['Entry_No' => 3, 'Appointment_Date' => '2025-02-10', 'Customer_No' => '10254', 'Customer_Name' => 'C', 'Salesperson_Code' => 'MER', 'Comment' => 'C', 'Released_by' => 'U', 'Release_Date' => '2025-02-11'], + ]; + + $result = DebitorDetailService::buildMeetingsTimeline($meetings); + + $this->assertSame(3, $result['kpis']['total']); + // All 3 are in the past (2025-02, 2025-03) + $this->assertArrayHasKey('2025-03', $result['timeline']['past']); + $this->assertArrayHasKey('2025-02', $result['timeline']['past']); + $this->assertCount(2, $result['timeline']['past']['2025-03']); + $this->assertCount(1, $result['timeline']['past']['2025-02']); + } + + // --- loadMeetings() --- + + public function testLoadMeetingsReturnsErrorForEmptyCustomerNo(): void + { + $service = $this->createService(); + $result = $service->loadMeetings(''); + + $this->assertFalse($result['ok']); + $this->assertSame('Missing customer number', $result['error']); + } + + public function testLoadMeetingsReturnsErrorWhenNotConfigured(): void + { + $service = $this->createService(null, false); + $result = $service->loadMeetings('10254'); + + $this->assertFalse($result['ok']); + $this->assertSame('BC connection not configured', $result['error']); + } + + public function testLoadMeetingsReturnsTimelineOnSuccess(): void + { + $futureDate = date('Y-m-d', strtotime('+7 days')); + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getMeetingsForCustomer')->with('10254')->willReturn([ + ['Entry_No' => 1, 'Appointment_Date' => $futureDate, 'Customer_No' => '10254', 'Customer_Name' => 'Test GmbH', 'Salesperson_Code' => 'MER', 'Comment' => 'Workshop', 'Released_by' => '', 'Release_Date' => '0001-01-01'], + ]); + + $service = $this->createService($gateway); + $result = $service->loadMeetings('10254'); + + $this->assertTrue($result['ok']); + $this->assertSame(1, $result['kpis']['total']); + $this->assertSame(1, $result['kpis']['upcoming_count']); + $this->assertArrayHasKey('upcoming', $result['timeline']); + $this->assertArrayHasKey('past', $result['timeline']); + } + + public function testLoadMeetingsReturnsErrorOnGatewayException(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getMeetingsForCustomer')->willThrowException(new \RuntimeException('Connection timeout')); + + $service = $this->createService($gateway); + $result = $service->loadMeetings('10254'); + + $this->assertFalse($result['ok']); + $this->assertSame('Connection timeout', $result['error']); + } } diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index 4328f0e..60d1ce8 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -2186,4 +2186,154 @@ line-height: 1.45; color: var(--app-muted, #6c757d); } + + /* ── Sales split layout (contacts 2/3 + meetings 1/3) ─ */ + + .helpdesk-sales-split { + display: grid; + grid-template-columns: 2fr 1fr; + gap: calc(var(--app-spacing) * 1.5); + align-items: start; + } + + .helpdesk-sales-split-main { + min-width: 0; + overflow: hidden; + } + + .helpdesk-sales-split-aside { + padding-left: calc(var(--app-spacing) * 1.5); + } + + @media (max-width: 860px) { + .helpdesk-sales-split { + grid-template-columns: 1fr; + } + + .helpdesk-sales-split-aside { + padding-left: 0; + padding-top: calc(var(--app-spacing) * 1); + } + } + + /* ── Meetings timeline ───────────────────────────────── */ + + .helpdesk-meetings-group-header { + font-size: var(--text-xs, 0.75rem); + font-weight: var(--font-semibold, 600); + letter-spacing: var(--tracking-wide, 0.025em); + text-transform: uppercase; + color: var(--app-muted, #6c757d); + padding-block: calc(var(--app-spacing) * 0.5); + margin-block-start: calc(var(--app-spacing) * 1.25); + border-bottom: 1px solid var(--app-border, #d0d7de); + } + + .helpdesk-meetings-group-header:first-child { + margin-block-start: 0; + } + + .helpdesk-meetings-group-header-past { + margin-block-start: calc(var(--app-spacing) * 1.5); + } + + .helpdesk-meetings-month { + padding-block-start: calc(var(--app-spacing) * 0.75); + } + + .helpdesk-meetings-month-label { + display: block; + font-size: var(--text-xs, 0.75rem); + font-weight: var(--font-medium, 500); + color: var(--app-muted, #6c757d); + padding-block-end: calc(var(--app-spacing) * 0.35); + } + + .helpdesk-meetings-card { + display: flex; + flex-direction: column; + gap: calc(var(--app-spacing) * 0.2); + padding: calc(var(--app-spacing) * 0.6) calc(var(--app-spacing) * 0.75); + border-left: 3px solid var(--app-border, #d0d7de); + border-radius: 0; + transition: background 0.15s ease; + } + + .helpdesk-meetings-card-released { + border-left-color: var(--app-success, #198754); + } + + .helpdesk-meetings-card-pending { + border-left-color: hsl(40 75% 55%); + } + + .helpdesk-meetings-card:hover { + background: var(--app-hover-background, hsl(0 0% 0% / 0.03)); + } + + .helpdesk-meetings-card + .helpdesk-meetings-card { + margin-block-start: calc(var(--app-spacing) * 0.6); + } + + .helpdesk-meetings-card-body { + min-width: 0; + } + + .helpdesk-meetings-card-comment { + margin: 0; + font-size: var(--text-sm, 0.875rem); + font-weight: var(--font-medium, 500); + line-height: 1.4; + color: var(--app-contrast, #1f2937); + } + + .helpdesk-meetings-card-secondary { + margin: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: calc(var(--app-spacing) * 0.35); + font-size: var(--text-xs, 0.75rem); + color: var(--app-muted, #6c757d); + } + + .helpdesk-meetings-card-context { + display: flex; + align-items: center; + gap: calc(var(--app-spacing) * 0.25); + } + + .helpdesk-meetings-badge { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.45rem; + border-radius: 999px; + font-size: 0.6875rem; + font-weight: var(--font-medium, 500); + line-height: 1.4; + } + + .helpdesk-meetings-badge-released { + background: hsl(142 64% 93%); + color: hsl(142 64% 28%); + } + + .helpdesk-meetings-badge-pending { + background: hsl(40 80% 92%); + color: hsl(40 60% 30%); + } + + @media (prefers-color-scheme: dark) { + .helpdesk-meetings-badge-released { + background: hsl(142 40% 18%); + color: hsl(142 60% 70%); + } + + .helpdesk-meetings-badge-pending { + background: hsl(40 40% 18%); + color: hsl(40 60% 70%); + } + } + } diff --git a/modules/helpdesk/web/js/helpdesk-detail.js b/modules/helpdesk/web/js/helpdesk-detail.js index e341a32..eea14e0 100644 --- a/modules/helpdesk/web/js/helpdesk-detail.js +++ b/modules/helpdesk/web/js/helpdesk-detail.js @@ -29,6 +29,7 @@ function init(container) { const communicationUrl = container.dataset.communicationUrl || ''; const fileDownloadUrl = container.dataset.fileDownloadUrl || ''; const salesDashboardUrl = container.dataset.salesDashboardUrl || ''; + const meetingsUrl = container.dataset.meetingsUrl || ''; const refreshFromUrl = (() => { const value = new URLSearchParams(window.location.search).get('refresh'); if (!value) return false; @@ -44,6 +45,7 @@ function init(container) { let supportDashboardPromise = null; let salesDashboardPromise = null; + let meetingsPromise = null; let contactsPromise = null; let ticketCategoriesPromise = null; let debitorCommunicationPromise = null; @@ -91,6 +93,21 @@ function init(container) { return salesDashboardPromise; } + function fetchMeetings() { + if (!meetingsPromise) { + if (!meetingsUrl) { + meetingsPromise = Promise.resolve({ ok: false, error: 'No meetings endpoint configured' }); + } else { + const params = new URLSearchParams({ customerNo }); + if (refreshFromUrl) params.set('refresh', '1'); + meetingsPromise = fetch(meetingsUrl + '?' + params.toString(), { credentials: 'same-origin' }) + .then(r => r.json()) + .catch(() => ({ ok: false, error: 'Network error' })); + } + } + return meetingsPromise; + } + function fetchContacts() { if (!contactsPromise) { const params = buildBaseParams(); @@ -983,9 +1000,107 @@ function init(container) { // Init contacts grid (server-side, same pattern as tickets) initContactsGrid(); + // Meetings timeline (async, doesn't block sales tab) + renderMeetingsSection(); + salesTabRendered = true; } + // All values inserted via esc() which escapes HTML entities — safe innerHTML usage (same pattern as renderContractTimeline, renderSystemRecommendations) + async function renderMeetingsSection() { + const loadingEl = document.getElementById('meetings-loading'); + const contentEl = document.getElementById('meetings-content'); + const emptyEl = document.getElementById('meetings-empty'); + const errorEl = document.getElementById('meetings-error'); + if (!loadingEl || !contentEl) return; + + loadingEl.hidden = false; + contentEl.hidden = true; + if (emptyEl) emptyEl.hidden = true; + if (errorEl) errorEl.hidden = true; + + const result = await fetchMeetings(); + + loadingEl.hidden = true; + + if (!result?.ok) { + if (errorEl) { + errorEl.innerHTML = errorNotice(t('Could not load meetings.')); // raw-html-ok: errorNotice returns escaped HTML + errorEl.hidden = false; + } + return; + } + + const kpis = result.kpis || {}; + const timeline = result.timeline || {}; + const upcoming = timeline.upcoming || {}; + const past = timeline.past || {}; + const hasEntries = Object.keys(upcoming).length > 0 || Object.keys(past).length > 0; + + if (!hasEntries) { + if (emptyEl) { + emptyEl.innerHTML = renderEmptyState(t('No meetings found for this customer.')); // raw-html-ok: renderEmptyState returns escaped HTML + emptyEl.hidden = false; + } + return; + } + + // Render timeline — all dynamic values passed through esc() which HTML-entity-escapes + const timelineEl = document.getElementById('meetings-timeline'); + if (!timelineEl) { contentEl.hidden = false; return; } + + let html = ''; + + if (Object.keys(upcoming).length > 0) { + html += `
${esc(t('Upcoming meetings'))}
`; + html += renderMeetingGroups(upcoming); + } + + if (Object.keys(past).length > 0) { + html += `
${esc(t('Past meetings'))}
`; + html += renderMeetingGroups(past); + } + + timelineEl.innerHTML = html; // raw-html-ok: all values escaped via esc() + contentEl.hidden = false; + } + + function renderMeetingGroups(groups) { + const monthFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'long' }); + const dateFmt = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }); + let html = ''; + + for (const [monthKey, entries] of Object.entries(groups)) { + const monthDate = new Date(monthKey + '-01'); + const monthLabel = Number.isNaN(monthDate.getTime()) ? monthKey : monthFmt.format(monthDate); + html += `
${esc(monthLabel)}`; + + for (const entry of entries) { + const d = new Date(entry.appointment_date); + const dateStr = Number.isNaN(d.getTime()) ? entry.appointment_date : dateFmt.format(d); + const statusCls = entry.is_released ? 'helpdesk-meetings-card-released' : 'helpdesk-meetings-card-pending'; + const badgeCls = entry.is_released ? 'helpdesk-meetings-badge-released' : 'helpdesk-meetings-badge-pending'; + const badgeLabel = entry.is_released ? t('Released') : t('Pending'); + + html += `
+

${esc(entry.comment || '—')}

+

+ + + · + ${esc(entry.salesperson_code)} + + ${esc(badgeLabel)} +

+
`; + } + + html += '
'; + } + + return html; + } + function renderContractTimeline(contracts) { const chartEl = document.getElementById('sales-trend-chart'); if (!chartEl || !contracts.length) return;