Files
thetool/application/WorkorderDashboard/WorkorderDashboardController.php
2026-01-28 10:12:16 +01:00

296 lines
15 KiB
PHP

<?php
class WorkorderDashboardController extends TTCrud
{
protected string $headerTitle = 'Arbeitsaufträge Dashboard';
protected bool $createText = false;
protected array $columns = [];
protected array $permissionCheck = ['RMLAdmin'];
protected array $additionalHead = [
"<link rel='stylesheet' href='/js/pages/WorkorderDashboard/WorkorderDashboard.css'>",
"<script src='https://cdn.jsdelivr.net/npm/chart.js'></script>",
"<script src='https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js'></script>",
"<script src='https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@1.0.1/dist/chartjs-adapter-moment.min.js'></script>"
];
protected array $statusLabels = [
'new' => 'Neu', 'assigned' => 'Zugewiesen', 'scheduled' => 'Geplant', 'in_progress' => 'In Bearbeitung',
'correction_requested' => 'Korrektur angefordert', 'intervention_required' => 'Eingriff erforderlich',
'civil_engineering_required' => 'Tiefbau benötigt', 'civil_engineering_completed' => 'Tiefbau abgeschlossen',
'problem_solved' => 'Problem gelöst', 'documented' => 'Dokumentiert', 'completed' => 'Abgeschlossen',
'charged' => 'Verrechnet', 'cancelled' => 'Abgebrochen', 'archived' => 'Archiviert',
];
protected array $statusColors = [
'new' => '#3b82f6', 'assigned' => '#06b6d4', 'scheduled' => '#8b5cf6', 'in_progress' => '#f59e0b',
'correction_requested' => '#ef4444', 'intervention_required' => '#dc2626', 'civil_engineering_required' => '#ea580c',
'civil_engineering_completed' => '#65a30d', 'problem_solved' => '#22c55e', 'documented' => '#14b8a6',
'completed' => '#10b981', 'charged' => '#8b5cf6', 'cancelled' => '#6b7280', 'archived' => '#9ca3af',
];
protected function indexAction()
{
$this->layout()->set('additionalHead', $this->additionalHead);
Helper::renderVue($this, 'WorkorderDashboard', $this->headerTitle, []);
}
protected function getFilterOptionsAction()
{
$tenants = WorkorderTenantConfigModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']);
$companies = WorkorderCompanyModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']);
self::returnJson([
'tenants' => array_map(fn($t) => ['value' => $t->id, 'text' => $t->name], $tenants),
'companies' => array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $companies),
'statuses' => array_map(fn($key, $label) => ['value' => $key, 'text' => $label], array_keys($this->statusLabels), $this->statusLabels),
'campaigns' => []
]);
}
protected function getCampaignsForTenantAction()
{
$tenantId = $this->postData['tenantId'] ?? null;
if (!$tenantId || !($config = WorkorderTenantConfigModel::get($tenantId))) {
self::returnJson([]);
return;
}
$networks = NetworkModel::search(['owner_id' => $config->addressId]);
if (empty($networks)) {
self::returnJson([]);
return;
}
$campaigns = PreordercampaignModel::search(['network_id' => array_map(fn($n) => $n->id, $networks)]);
$options = array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $campaigns);
usort($options, fn($a, $b) => strcmp($a['text'], $b['text']));
self::returnJson($options);
}
protected function getDashboardDataAction()
{
$tenantId = $this->postData['tenantId'] ?? null;
$dateFrom = $this->postData['dateFrom'] ?? null;
$dateTo = $this->postData['dateTo'] ?? null;
$companyIds = $this->postData['companyIds'] ?? [];
$statuses = $this->postData['statuses'] ?? [];
$campaignIds = $this->postData['campaignIds'] ?? [];
if (!$tenantId) self::sendError('Mandant muss ausgewählt werden.');
$config = WorkorderTenantConfigModel::get($tenantId);
if (!$config) self::sendError('Mandant nicht gefunden.');
$networks = NetworkModel::search(['owner_id' => $config->addressId]);
$tenantCampaignIds = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => array_map(fn($n) => $n->id, $networks)]));
if (empty($tenantCampaignIds)) {
self::returnJson($this->getEmptyDashboardData());
return;
}
if (!empty($campaignIds)) $tenantCampaignIds = array_intersect($tenantCampaignIds, $campaignIds);
$db = FronkDB::singleton();
$whereConditions = ["p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ")"];
if ($dateFrom) $whereConditions[] = "w.`create` >= " . intval($dateFrom);
if ($dateTo) $whereConditions[] = "w.`create` <= " . intval($dateTo);
if (!empty($companyIds)) $whereConditions[] = "w.companyId IN (" . implode(',', array_map('intval', $companyIds)) . ")";
if (!empty($statuses)) $whereConditions[] = "w.status IN (" . implode(',', array_map(fn($s) => "'" . $db->escape($s) . "'", $statuses)) . ")";
$whereClause = implode(' AND ', $whereConditions);
self::returnJson([
'kpis' => $this->getKPIs($db, $whereClause, $tenantCampaignIds, $dateFrom, $dateTo),
'statusDistribution' => $this->getStatusDistribution($db, $whereClause),
'companyPerformance' => $this->getCompanyPerformance($db, $whereClause),
'timeTrends' => $this->getTimeTrends($db, $tenantCampaignIds, $dateFrom, $dateTo, $companyIds),
'companyStatusCampaign' => $this->getCompanyStatusCampaign($db, $whereClause),
'interventionRates' => $this->getInterventionRates($db, $whereClause),
'statusTransitions' => $this->getStatusTransitions($db, $tenantCampaignIds, $dateFrom, $dateTo),
]);
}
private function getKPIs($db, $whereClause, $tenantCampaignIds, $dateFrom, $dateTo): array
{
$total = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause")->fetch_assoc()['c'] ?? 0;
$completed = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause AND w.status IN ('completed', 'charged')")->fetch_assoc()['c'] ?? 0;
$pending = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause AND w.status IN ('new', 'assigned', 'scheduled', 'in_progress', 'documented')")->fetch_assoc()['c'] ?? 0;
$issues = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause AND w.status IN ('intervention_required', 'correction_requested')")->fetch_assoc()['c'] ?? 0;
return [
'total' => (int)$total,
'completed' => (int)$completed,
'pending' => (int)$pending,
'issues' => (int)$issues,
'interventionRate' => $total > 0 ? round(($issues / $total) * 100, 1) : 0,
'avgCompletionDays' => $this->calculateAvgCompletionTime($db, $tenantCampaignIds),
];
}
private function calculateAvgCompletionTime($db, $tenantCampaignIds): ?float
{
$sql = "SELECT w.id,
MIN(CASE WHEN wj.statusChange LIKE '%Zugewiesen%' OR wj.statusChange LIKE '%-> Zugewiesen' THEN wj.`create` END) as assigned_time,
MIN(CASE WHEN wj.statusChange LIKE '%-> Abgeschlossen%' THEN wj.`create` END) as completed_time
FROM thetool.Workorder w
JOIN thetool.Preorder p ON w.preorderId = p.id
JOIN thetool.WorkorderJournal wj ON w.id = wj.workorderId
WHERE p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ") AND w.status IN ('completed', 'charged')
GROUP BY w.id HAVING assigned_time IS NOT NULL AND completed_time IS NOT NULL";
$result = $db->query($sql);
$totalDays = $count = 0;
while ($row = $result->fetch_assoc()) {
if ($row['completed_time'] > $row['assigned_time']) {
$totalDays += ($row['completed_time'] - $row['assigned_time']) / 86400;
$count++;
}
}
return $count > 0 ? round($totalDays / $count, 1) : null;
}
private function getStatusDistribution($db, $whereClause): array
{
$result = $db->query("SELECT w.status, COUNT(*) as count FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause GROUP BY w.status ORDER BY count DESC");
$distribution = [];
while ($row = $result->fetch_assoc()) {
$distribution[] = [
'status' => $row['status'],
'label' => $this->statusLabels[$row['status']] ?? $row['status'],
'count' => (int)$row['count'],
'color' => $this->statusColors[$row['status']] ?? '#6b7280',
];
}
return $distribution;
}
private function getCompanyPerformance($db, $whereClause): array
{
$sql = "SELECT wc.name as company, wc.id as companyId,
SUM(CASE WHEN w.status IN ('completed', 'charged') THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN w.status IN ('new', 'assigned', 'scheduled', 'in_progress', 'documented') THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN w.status IN ('intervention_required', 'correction_requested') THEN 1 ELSE 0 END) as issues,
COUNT(*) as total
FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id
LEFT JOIN thetool.WorkorderCompany wc ON w.companyId = wc.id
WHERE $whereClause AND w.companyId IS NOT NULL GROUP BY wc.id, wc.name ORDER BY total DESC";
$result = $db->query($sql);
$performance = [];
while ($row = $result->fetch_assoc()) {
$performance[] = [
'company' => $row['company'] ?? 'Nicht zugewiesen',
'companyId' => (int)$row['companyId'],
'completed' => (int)$row['completed'],
'pending' => (int)$row['pending'],
'issues' => (int)$row['issues'],
'total' => (int)$row['total'],
];
}
return $performance;
}
private function getTimeTrends($db, $tenantCampaignIds, $dateFrom, $dateTo, $companyIds): array
{
$where = ["p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ")"];
if ($dateFrom) $where[] = "w.`create` >= " . intval($dateFrom);
if ($dateTo) $where[] = "w.`create` <= " . intval($dateTo);
if (!empty($companyIds)) $where[] = "w.companyId IN (" . implode(',', array_map('intval', $companyIds)) . ")";
$sql = "SELECT DATE(FROM_UNIXTIME(w.`create`)) as date, COUNT(*) as created,
SUM(CASE WHEN w.status IN ('completed', 'charged') THEN 1 ELSE 0 END) as completed
FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id
WHERE " . implode(' AND ', $where) . " GROUP BY DATE(FROM_UNIXTIME(w.`create`)) ORDER BY date ASC";
$result = $db->query($sql);
$trends = [];
while ($row = $result->fetch_assoc()) {
$trends[] = ['date' => $row['date'], 'created' => (int)$row['created'], 'completed' => (int)$row['completed']];
}
return $trends;
}
private function getCompanyStatusCampaign($db, $whereClause): array
{
$sql = "SELECT wc.name as company, w.status, pc.name as campaign, COUNT(*) as count
FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id
JOIN thetool.Preordercampaign pc ON p.preordercampaign_id = pc.id
LEFT JOIN thetool.WorkorderCompany wc ON w.companyId = wc.id
WHERE $whereClause AND w.companyId IS NOT NULL
GROUP BY wc.name, w.status, pc.name ORDER BY wc.name, count DESC";
$result = $db->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$key = ($row['company'] ?? 'Nicht zugewiesen') . '|' . $row['status'];
if (!isset($data[$key])) {
$data[$key] = [
'company' => $row['company'] ?? 'Nicht zugewiesen',
'status' => $row['status'],
'statusLabel' => $this->statusLabels[$row['status']] ?? $row['status'],
'count' => 0,
'campaigns' => [],
];
}
$data[$key]['count'] += (int)$row['count'];
$data[$key]['campaigns'][] = ['name' => $row['campaign'], 'count' => (int)$row['count']];
}
$result = array_values($data);
usort($result, fn($a, $b) => $b['count'] - $a['count']);
return $result;
}
private function getInterventionRates($db, $whereClause): array
{
$sql = "SELECT wc.name as company, COUNT(*) as total,
SUM(CASE WHEN w.status = 'intervention_required' THEN 1 ELSE 0 END) as interventions,
SUM(CASE WHEN w.status = 'correction_requested' THEN 1 ELSE 0 END) as corrections
FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id
LEFT JOIN thetool.WorkorderCompany wc ON w.companyId = wc.id
WHERE $whereClause AND w.companyId IS NOT NULL GROUP BY wc.name
HAVING COUNT(*) >= 5 ORDER BY (SUM(CASE WHEN w.status IN ('intervention_required', 'correction_requested') THEN 1 ELSE 0 END) / COUNT(*)) DESC";
$result = $db->query($sql);
$rates = [];
while ($row = $result->fetch_assoc()) {
$total = (int)$row['total'];
$issueCount = (int)$row['interventions'] + (int)$row['corrections'];
$rates[] = [
'company' => $row['company'] ?? 'Nicht zugewiesen',
'total' => $total,
'interventions' => (int)$row['interventions'],
'corrections' => (int)$row['corrections'],
'rate' => $total > 0 ? round(($issueCount / $total) * 100, 1) : 0,
];
}
return $rates;
}
private function getStatusTransitions($db, $tenantCampaignIds, $dateFrom, $dateTo): array
{
$where = ["p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ")", "wj.statusChange IS NOT NULL"];
if ($dateFrom) $where[] = "wj.`create` >= " . intval($dateFrom);
if ($dateTo) $where[] = "wj.`create` <= " . intval($dateTo);
$sql = "SELECT wj.statusChange, COUNT(*) as count FROM thetool.WorkorderJournal wj
JOIN thetool.Workorder w ON wj.workorderId = w.id JOIN thetool.Preorder p ON w.preorderId = p.id
WHERE " . implode(' AND ', $where) . " GROUP BY wj.statusChange ORDER BY count DESC LIMIT 15";
$result = $db->query($sql);
$transitions = [];
while ($row = $result->fetch_assoc()) {
$transitions[] = ['transition' => $row['statusChange'], 'count' => (int)$row['count']];
}
return $transitions;
}
private function getEmptyDashboardData(): array
{
return [
'kpis' => ['total' => 0, 'completed' => 0, 'pending' => 0, 'issues' => 0, 'interventionRate' => 0, 'avgCompletionDays' => null],
'statusDistribution' => [], 'companyPerformance' => [], 'timeTrends' => [],
'companyStatusCampaign' => [], 'interventionRates' => [], 'statusTransitions' => [],
];
}
}