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:
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'),
|
||||
|
||||
72
modules/helpdesk/pages/helpdesk/debitor-meetings-data().php
Normal file
72
modules/helpdesk/pages/helpdesk/debitor-meetings-data().php
Normal 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);
|
||||
@@ -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')
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user