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

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