feat(helpdesk): add debitor meetings tab with upcoming/past meeting display

Adds a meetings section to the debitor detail page, fetching meeting data from BC OData API with cache control support. Includes timeline UI for upcoming and past meetings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 14:13:29 +02:00
parent 4e359fe659
commit 42efdb8102
12 changed files with 679 additions and 15 deletions

View File

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

View File

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

View File

@@ -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<int, array<string, mixed>>
*/
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.
*

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
data-communication-url="<?php e(lurl('helpdesk/debitor-communication-data')); ?>"
data-file-download-url="<?php e(lurl('helpdesk/ticket-file-data')); ?>"
data-sales-dashboard-url="<?php e(lurl('helpdesk/debitor-sales-dashboard-data')); ?>"
data-meetings-url="<?php e(lurl('helpdesk/debitor-meetings-data')); ?>"
data-controlling-dashboard-url="<?php e(lurl('helpdesk/debitor-controlling-dashboard-data')); ?>"
>
<section>
@@ -201,18 +202,37 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
<div id="sales-trend-chart"></div>
</section>
<section id="sales-contacts-section">
<h3 class="helpdesk-support-section-title"><?php e(t('Contacts')); ?></h3>
<?php
$filterUiNamespace = 'helpdesk-contacts';
$searchToolbarFilterSchema = $contactsSearchToolbarFilterSchema;
$drawerToolbarFilterSchema = $contactsDrawerToolbarFilterSchema;
$toolbarFilterState = $contactsToolbarFilterState;
$toolbarOptionSets = $contactsToolbarOptionSets;
require templatePath('partials/app-list-filters.phtml');
?>
<div id="helpdesk-contacts-grid"></div>
</section>
<div class="helpdesk-sales-split">
<section id="sales-contacts-section" class="helpdesk-sales-split-main">
<h3 class="helpdesk-support-section-title"><?php e(t('Contacts')); ?></h3>
<?php
$filterUiNamespace = 'helpdesk-contacts';
$searchToolbarFilterSchema = $contactsSearchToolbarFilterSchema;
$drawerToolbarFilterSchema = $contactsDrawerToolbarFilterSchema;
$toolbarFilterState = $contactsToolbarFilterState;
$toolbarOptionSets = $contactsToolbarOptionSets;
require templatePath('partials/app-list-filters.phtml');
?>
<div id="helpdesk-contacts-grid"></div>
</section>
<section id="sales-meetings-section" class="helpdesk-sales-split-aside">
<h3 class="helpdesk-support-section-title"><?php e(t('Meetings')); ?></h3>
<div id="meetings-loading">
<?php for ($i = 0; $i < 3; $i++): ?>
<div class="helpdesk-skeleton-row">
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-cell"></div>
</div>
<?php endfor; ?>
</div>
<div id="meetings-content" hidden>
<div id="meetings-timeline"></div>
</div>
<div id="meetings-empty" hidden></div>
<div id="meetings-error" hidden></div>
</section>
</div>
</div>
</div>
@@ -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'),

View File

@@ -0,0 +1,72 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
$request = requestInput();
if ($request->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);

View File

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

View File

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

View File

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

View File

@@ -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 += `<div class="helpdesk-meetings-group-header">${esc(t('Upcoming meetings'))}</div>`;
html += renderMeetingGroups(upcoming);
}
if (Object.keys(past).length > 0) {
html += `<div class="helpdesk-meetings-group-header helpdesk-meetings-group-header-past">${esc(t('Past meetings'))}</div>`;
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 += `<div class="helpdesk-meetings-month"><span class="helpdesk-meetings-month-label">${esc(monthLabel)}</span>`;
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 += `<article class="helpdesk-meetings-card ${statusCls}">
<p class="helpdesk-meetings-card-comment">${esc(entry.comment || '—')}</p>
<p class="helpdesk-meetings-card-secondary">
<span class="helpdesk-meetings-card-context">
<time datetime="${esc(entry.appointment_date)}">${esc(dateStr)}</time>
<span>·</span>
<span>${esc(entry.salesperson_code)}</span>
</span>
<span class="helpdesk-meetings-badge ${badgeCls}" aria-label="${esc(badgeLabel)}">${esc(badgeLabel)}</span>
</p>
</article>`;
}
html += '</div>';
}
return html;
}
function renderContractTimeline(contracts) {
const chartEl = document.getElementById('sales-trend-chart');
if (!chartEl || !contracts.length) return;