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

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