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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user