From 6a85d4a98050cc1cb6b016e41500426fa599472b Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Wed, 28 Jan 2026 10:12:16 +0100 Subject: [PATCH] improve and add dashboard --- Layout/default/menu.php | 2 +- .../WorkorderAdminController.php | 1 + .../WorkorderBase/WorkorderBaseController.php | 45 +++ .../WorkorderDashboardController.php | 295 ++++++++++++++++++ .../WorkorderTenantConfigController.php | 1 + .../WorkorderTenantConfigModel.php | 1 + ...0260128074700_add_auto_complete_filter.php | 36 +++ .../WorkorderDashboard/WorkorderDashboard.css | 135 ++++++++ .../WorkorderDashboard/WorkorderDashboard.js | 276 ++++++++++++++++ 9 files changed, 791 insertions(+), 1 deletion(-) create mode 100644 application/WorkorderDashboard/WorkorderDashboardController.php create mode 100644 db/migrations/20260128074700_add_auto_complete_filter.php create mode 100644 public/js/pages/WorkorderDashboard/WorkorderDashboard.css create mode 100644 public/js/pages/WorkorderDashboard/WorkorderDashboard.js diff --git a/Layout/default/menu.php b/Layout/default/menu.php index 82ce59296..7061bab1a 100644 --- a/Layout/default/menu.php +++ b/Layout/default/menu.php @@ -141,9 +141,9 @@ is(["Admin","netowner","pipeplanner","pipeplanner"]) && $me->is("employee")): ?>
  • "> Verteiler und Schächte
  • is(["Admin","netowner","lineplanner","lineworker"]) && $me->is("employee")): ?>
  • "> Rohrverzeichnis
  • is(["Admin","netowner","lineplanner","lineworker"]) && $me->is("employee")): ?>
  • "> Kabelverzeichnis
  • - can("RMLCompany")): ?>
  • "> Arbeitsaufträge
  • can("RMLAdmin")): ?>
  • "> Arbeitsaufträge-Management
  • + can("RMLAdmin")): ?>
  • "> Arbeitsaufträge-Dashboard
  • can("WorkorderMph")): ?>
  • "> MPH Arbeitsaufträge
  • can("WorkorderMphAdmin")): ?>
  • "> MPH Arbeitsaufträge Verwaltung
  • diff --git a/application/WorkorderAdmin/WorkorderAdminController.php b/application/WorkorderAdmin/WorkorderAdminController.php index c0771036f..40259cb0b 100644 --- a/application/WorkorderAdmin/WorkorderAdminController.php +++ b/application/WorkorderAdmin/WorkorderAdminController.php @@ -54,6 +54,7 @@ class WorkorderAdminController extends WorkorderBaseController public function indexAction() { $this->createWorkordersFromPreorders(); + $this->autoCompleteDocumentedWorkorders(); $this->archiveWorkorders(); parent::indexAction(); } diff --git a/application/WorkorderBase/WorkorderBaseController.php b/application/WorkorderBase/WorkorderBaseController.php index 68e2066d0..8fac50f33 100644 --- a/application/WorkorderBase/WorkorderBaseController.php +++ b/application/WorkorderBase/WorkorderBaseController.php @@ -283,5 +283,50 @@ class WorkorderBaseController extends TTCrud } file_put_contents($lockFile, time()); } + + protected function autoCompleteDocumentedWorkorders() + { + $lockFile = TEMP_DIR . "/task_auto_complete_workorders.lock"; + if (file_exists($lockFile) && (time() - intval(file_get_contents($lockFile))) < 300) return; + + foreach (WorkorderTenantConfigModel::getAll() as $config) { + $filter = json_decode($config->autoCompleteFilter ?? '', true); + if (empty($filter)) continue; + + $networks = NetworkModel::search(['owner_id' => $config->addressId]); + if (empty($networks)) continue; + + $networkIds = array_map(fn($n) => $n->id, $networks); + $campaignIds = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds])); + if (empty($campaignIds)) continue; + + $filter['preordercampaign_id'] = $campaignIds; + $matchingPreorders = PreorderModel::searchActive($filter); + if (empty($matchingPreorders)) continue; + + $preorderIds = array_map(fn($p) => $p->id, $matchingPreorders); + $preorderIdSet = array_flip($preorderIds); + + $workorders = WorkorderModel::getAll([ + 'status' => ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', 'problem_solved', 'documented', 'archived'], + 'preorderId' => $preorderIds + ]); + + foreach ($workorders as $workorder) { + if (!isset($preorderIdSet[$workorder->preorderId])) continue; + $oldStatus = $workorder->status; + $workorder->status = 'completed'; + WorkorderModel::update((array)$workorder); + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => 'Dokumentation automatisch akzeptiert (Auto-Complete Filter).', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('completed'), + 'create' => time(), + 'createBy' => 1, + ]); + } + } + file_put_contents($lockFile, time()); + } //endregion } diff --git a/application/WorkorderDashboard/WorkorderDashboardController.php b/application/WorkorderDashboard/WorkorderDashboardController.php new file mode 100644 index 000000000..ccb654cf2 --- /dev/null +++ b/application/WorkorderDashboard/WorkorderDashboardController.php @@ -0,0 +1,295 @@ +", + "", + "", + "" + ]; + + 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' => [], + ]; + } +} diff --git a/application/WorkorderTenantConfig/WorkorderTenantConfigController.php b/application/WorkorderTenantConfig/WorkorderTenantConfigController.php index 8a7dc0099..064fd2787 100644 --- a/application/WorkorderTenantConfig/WorkorderTenantConfigController.php +++ b/application/WorkorderTenantConfig/WorkorderTenantConfigController.php @@ -20,6 +20,7 @@ class WorkorderTenantConfigController extends TTCrud { $data['interventionTypes'] = json_encode($data['interventionTypes'] ?? []); $data['workorderCreationFilters'] ??= '{}'; $data['workorderActiveFilters'] ??= '{}'; + $data['autoCompleteFilter'] ??= null; if (empty($data['id'])) { $data['create'] = time(); diff --git a/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php b/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php index 3386d18dc..723830a83 100644 --- a/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php +++ b/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php @@ -8,6 +8,7 @@ class WorkorderTenantConfigModel extends TTCrudBaseModel { public string $documentationTypes; // JSON public string $workorderCreationFilters; // JSON public ?string $workorderActiveFilters; // JSON + public ?string $autoCompleteFilter; // JSON public ?string $interventionTypes; // JSON public int $civilEngineeringDocsRequired; public int $requireCableLength; diff --git a/db/migrations/20260128074700_add_auto_complete_filter.php b/db/migrations/20260128074700_add_auto_complete_filter.php new file mode 100644 index 000000000..cb95ef5d5 --- /dev/null +++ b/db/migrations/20260128074700_add_auto_complete_filter.php @@ -0,0 +1,36 @@ +getEnvironment() !== 'thetool') { + return; + } + + $workorderTenantConfigTable = $this->table('WorkorderTenantConfig'); + if (!$workorderTenantConfigTable->hasColumn('autoCompleteFilter')) + $workorderTenantConfigTable->addColumn('autoCompleteFilter', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG, + 'after' => 'workorderActiveFilters', + ]) + ->save(); + } + + public function down(): void + { + if ($this->getEnvironment() !== 'thetool') { + return; + } + + $workorderTenantConfigTable = $this->table('WorkorderTenantConfig'); + if ($workorderTenantConfigTable->hasColumn('autoCompleteFilter')) + $workorderTenantConfigTable->removeColumn('autoCompleteFilter')->save(); + } +} diff --git a/public/js/pages/WorkorderDashboard/WorkorderDashboard.css b/public/js/pages/WorkorderDashboard/WorkorderDashboard.css new file mode 100644 index 000000000..7aad806cb --- /dev/null +++ b/public/js/pages/WorkorderDashboard/WorkorderDashboard.css @@ -0,0 +1,135 @@ +.tt-scope.workorder-dashboard { + --wd-primary: #4f46e5; --wd-primary-light: #6366f1; + --wd-success: #10b981; --wd-success-light: #34d399; + --wd-warning: #f59e0b; --wd-warning-light: #fbbf24; + --wd-danger: #ef4444; --wd-danger-light: #f87171; + --wd-info: #06b6d4; --wd-info-light: #22d3ee; + --wd-gray: #6b7280; --wd-gray-light: #9ca3af; + --wd-card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --wd-card-shadow-hover: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --wd-card-radius: 12px; --wd-card-radius-sm: 8px; + padding: 0; min-height: 100vh; + background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%); +} + +.tt-scope.workorder-dashboard .filter-bar { + background: white; padding: 1rem 1.5rem; border-bottom: 1px solid #e5e7eb; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); position: sticky; top: 0; z-index: 100; +} +.tt-scope.workorder-dashboard .filter-bar__inner { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end; } +.tt-scope.workorder-dashboard .filter-bar__inner > .form-group { margin-bottom: 0; } +.tt-scope.workorder-dashboard .filter-bar .tt-select { min-width: 200px; } +.tt-scope.workorder-dashboard .filter-bar .tt-select:first-child { min-width: 220px; } +.tt-scope.workorder-dashboard .filter-bar .form-group input[type="text"] { min-width: 240px; } +.tt-scope.workorder-dashboard .filter-bar .refresh-btn { height: 31px; margin-bottom: 0; align-self: flex-end; } + +.tt-scope.workorder-dashboard .refresh-btn { + display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; + background: linear-gradient(135deg, var(--wd-primary), var(--wd-primary-light)); + border: none; border-radius: var(--wd-card-radius-sm); color: white; font-weight: 500; + transition: transform 0.2s, box-shadow 0.2s; +} +.tt-scope.workorder-dashboard .refresh-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(79, 70, 229, 0.4); } +.tt-scope.workorder-dashboard .refresh-btn:disabled { opacity: 0.6; cursor: not-allowed; } + +.tt-scope.workorder-dashboard .dashboard-content { padding: 1.5rem; display: flex; flex-direction: column; gap: 1.5rem; } + +.tt-scope.workorder-dashboard .kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; } +.tt-scope.workorder-dashboard .kpi-card { + display: flex; align-items: center; gap: 1rem; padding: 1.25rem; + border-radius: var(--wd-card-radius); box-shadow: var(--wd-card-shadow); + color: white; transition: transform 0.2s, box-shadow 0.2s; +} +.tt-scope.workorder-dashboard .kpi-card:hover { transform: translateY(-2px); box-shadow: var(--wd-card-shadow-hover); } +.tt-scope.workorder-dashboard .kpi-card--primary { background: linear-gradient(135deg, var(--wd-primary), var(--wd-primary-light)); } +.tt-scope.workorder-dashboard .kpi-card--success { background: linear-gradient(135deg, var(--wd-success), var(--wd-success-light)); } +.tt-scope.workorder-dashboard .kpi-card--warning { background: linear-gradient(135deg, var(--wd-warning), var(--wd-warning-light)); } +.tt-scope.workorder-dashboard .kpi-card--danger { background: linear-gradient(135deg, var(--wd-danger), var(--wd-danger-light)); } +.tt-scope.workorder-dashboard .kpi-card--info { background: linear-gradient(135deg, var(--wd-info), var(--wd-info-light)); } +.tt-scope.workorder-dashboard .kpi-card__icon { + width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; + background: rgba(255, 255, 255, 0.2); border-radius: 50%; font-size: 1.25rem; +} +.tt-scope.workorder-dashboard .kpi-card__content { flex: 1; } +.tt-scope.workorder-dashboard .kpi-card__value { font-size: 1.75rem; font-weight: 700; line-height: 1.2; } +.tt-scope.workorder-dashboard .kpi-card__title { font-size: 0.875rem; font-weight: 500; opacity: 0.9; } +.tt-scope.workorder-dashboard .kpi-card__subtitle { font-size: 0.75rem; opacity: 0.75; margin-top: 0.25rem; } + +.tt-scope.workorder-dashboard .charts-row { display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; } +.tt-scope.workorder-dashboard .analytics-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; } +@media (max-width: 1024px) { .tt-scope.workorder-dashboard .charts-row, .tt-scope.workorder-dashboard .analytics-row { grid-template-columns: 1fr; } } + +.tt-scope.workorder-dashboard .chart-card { + background: white; border-radius: var(--wd-card-radius); box-shadow: var(--wd-card-shadow); + padding: 1.5rem; transition: box-shadow 0.2s; +} +.tt-scope.workorder-dashboard .chart-card:hover { box-shadow: var(--wd-card-shadow-hover); } +.tt-scope.workorder-dashboard .chart-card--wide { grid-column: span 1; } +.tt-scope.workorder-dashboard .chart-card--full { width: 100%; } +.tt-scope.workorder-dashboard .chart-card__title { + display: flex; align-items: center; gap: 0.5rem; font-size: 1rem; font-weight: 600; + color: #1f2937; margin: 0 0 1rem 0; padding-bottom: 0.75rem; border-bottom: 1px solid #f3f4f6; +} +.tt-scope.workorder-dashboard .chart-card__title i { color: var(--wd-primary); } + +.tt-scope.workorder-dashboard .chart-wrapper { height: 250px; position: relative; } +.tt-scope.workorder-dashboard .chart-wrapper--large { height: 300px; } +.tt-scope.workorder-dashboard .chart-wrapper--wide { height: 200px; } + +.tt-scope.workorder-dashboard .detail-table-wrapper { overflow-x: auto; max-height: 400px; overflow-y: auto; } +.tt-scope.workorder-dashboard .detail-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } +.tt-scope.workorder-dashboard .detail-table th { + position: sticky; top: 0; background: #f9fafb; padding: 0.75rem 1rem; + text-align: left; font-weight: 600; color: #374151; border-bottom: 2px solid #e5e7eb; +} +.tt-scope.workorder-dashboard .detail-table td { padding: 0.75rem 1rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; } +.tt-scope.workorder-dashboard .detail-table tr:hover td { background: #f9fafb; } +.tt-scope.workorder-dashboard .company-cell { font-weight: 500; color: #1f2937; } +.tt-scope.workorder-dashboard .count-cell { font-weight: 600; color: var(--wd-primary); } +.tt-scope.workorder-dashboard .campaigns-cell { display: flex; flex-wrap: wrap; gap: 0.5rem; } +.tt-scope.workorder-dashboard .campaign-tag { display: inline-block; padding: 0.25rem 0.5rem; background: #f3f4f6; border-radius: 4px; font-size: 0.75rem; color: #4b5563; } + +.tt-scope.workorder-dashboard .status-badge { + display: inline-block; padding: 0.25rem 0.75rem; border-radius: 9999px; + font-size: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.025em; +} +.tt-scope.workorder-dashboard .status-badge--completed, .tt-scope.workorder-dashboard .status-badge--charged { background: rgba(16, 185, 129, 0.1); color: #059669; } +.tt-scope.workorder-dashboard .status-badge--assigned, .tt-scope.workorder-dashboard .status-badge--new { background: rgba(59, 130, 246, 0.1); color: #2563eb; } +.tt-scope.workorder-dashboard .status-badge--scheduled, .tt-scope.workorder-dashboard .status-badge--in_progress { background: rgba(245, 158, 11, 0.1); color: #d97706; } +.tt-scope.workorder-dashboard .status-badge--intervention_required, .tt-scope.workorder-dashboard .status-badge--correction_requested { background: rgba(239, 68, 68, 0.1); color: #dc2626; } +.tt-scope.workorder-dashboard .status-badge--documented, .tt-scope.workorder-dashboard .status-badge--problem_solved { background: rgba(20, 184, 166, 0.1); color: #0d9488; } +.tt-scope.workorder-dashboard .status-badge--archived, .tt-scope.workorder-dashboard .status-badge--cancelled { background: rgba(107, 114, 128, 0.1); color: #4b5563; } + +.tt-scope.workorder-dashboard .intervention-rates { display: flex; flex-direction: column; gap: 0.375rem; max-height: 300px; overflow-y: auto; } +.tt-scope.workorder-dashboard .intervention-rate-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.375rem 0.5rem; background: #f9fafb; border-radius: var(--wd-card-radius-sm); } +.tt-scope.workorder-dashboard .intervention-rate-item .company-name { flex: 0 0 120px; font-weight: 500; font-size: 0.8125rem; color: #1f2937; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tt-scope.workorder-dashboard .intervention-rate-item__bar { flex: 1; height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; min-width: 60px; } +.tt-scope.workorder-dashboard .intervention-rate-item__bar .bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; } +.tt-scope.workorder-dashboard .intervention-rate-item__bar .bar-fill.rate--success { background: var(--wd-success); } +.tt-scope.workorder-dashboard .intervention-rate-item__bar .bar-fill.rate--warning { background: var(--wd-warning); } +.tt-scope.workorder-dashboard .intervention-rate-item__bar .bar-fill.rate--danger { background: var(--wd-danger); } +.tt-scope.workorder-dashboard .intervention-rate-item .rate-value { flex: 0 0 50px; font-weight: 600; font-size: 0.8125rem; text-align: right; } +.tt-scope.workorder-dashboard .intervention-rate-item .rate-value.rate--success { color: var(--wd-success); } +.tt-scope.workorder-dashboard .intervention-rate-item .rate-value.rate--warning { color: var(--wd-warning); } +.tt-scope.workorder-dashboard .intervention-rate-item .rate-value.rate--danger { color: var(--wd-danger); } +.tt-scope.workorder-dashboard .intervention-rate-item__details { flex: 0 0 auto; display: flex; gap: 0.5rem; font-size: 0.6875rem; color: var(--wd-gray); } + +.tt-scope.workorder-dashboard .transitions-list { display: flex; flex-direction: column; gap: 0.5rem; max-height: 300px; overflow-y: auto; } +.tt-scope.workorder-dashboard .transition-item { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0.75rem; background: #f9fafb; border-radius: var(--wd-card-radius-sm); font-size: 0.875rem; } +.tt-scope.workorder-dashboard .transition-label { color: #374151; } +.tt-scope.workorder-dashboard .transition-count { font-weight: 600; color: var(--wd-primary); background: rgba(79, 70, 229, 0.1); padding: 0.25rem 0.5rem; border-radius: 4px; } + +.tt-scope.workorder-dashboard .loading-overlay { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 4rem; color: var(--wd-gray); } +.tt-scope.workorder-dashboard .spinner { width: 48px; height: 48px; border: 3px solid #e5e7eb; border-top-color: var(--wd-primary); border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 1rem; } +@keyframes spin { to { transform: rotate(360deg); } } +.tt-scope.workorder-dashboard .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 4rem; text-align: center; color: var(--wd-gray); } +.tt-scope.workorder-dashboard .empty-state i { font-size: 4rem; margin-bottom: 1rem; opacity: 0.3; } +.tt-scope.workorder-dashboard .empty-state h3 { margin: 0 0 0.5rem 0; color: #374151; } +.tt-scope.workorder-dashboard .empty-state p { margin: 0; } +.tt-scope.workorder-dashboard .no-data { text-align: center; padding: 2rem; color: var(--wd-gray); font-style: italic; } + +@media (max-width: 768px) { + .tt-scope.workorder-dashboard .filter-bar__inner { flex-direction: column; align-items: stretch; } + .tt-scope.workorder-dashboard .kpi-row { grid-template-columns: 1fr 1fr; } + .tt-scope.workorder-dashboard .kpi-card { flex-direction: column; text-align: center; } +} diff --git a/public/js/pages/WorkorderDashboard/WorkorderDashboard.js b/public/js/pages/WorkorderDashboard/WorkorderDashboard.js new file mode 100644 index 000000000..2506dead6 --- /dev/null +++ b/public/js/pages/WorkorderDashboard/WorkorderDashboard.js @@ -0,0 +1,276 @@ +Vue.component('wd-kpi-card', { + props: { + title: { type: String, required: true }, + value: { type: [Number, String], required: true }, + icon: { type: String, default: 'fas fa-chart-bar' }, + color: { type: String, default: 'primary' }, + suffix: { type: String, default: '' }, + subtitle: { type: String, default: '' } + }, + template: ` +
    +
    +
    +
    {{ value }}{{ suffix }}
    +
    {{ title }}
    +
    {{ subtitle }}
    +
    +
    + ` +}); + +Vue.component('wd-status-chart', { + props: { data: { type: Array, required: true } }, + template: '
    ', + data() { return { chart: null }; }, + mounted() { this.renderChart(); }, + watch: { data: { handler() { this.renderChart(); }, deep: true } }, + methods: { + renderChart() { + if (this.chart) this.chart.destroy(); + if (!this.data || this.data.length === 0) return; + this.chart = new Chart(this.$refs.chart.getContext('2d'), { + type: 'doughnut', + data: { + labels: this.data.map(d => d.label), + datasets: [{ data: this.data.map(d => d.count), backgroundColor: this.data.map(d => d.color), borderWidth: 2, borderColor: '#fff' }] + }, + options: { + responsive: true, maintainAspectRatio: false, cutout: '60%', + plugins: { + legend: { position: 'right', labels: { padding: 15, usePointStyle: true, font: { size: 11 } } }, + tooltip: { callbacks: { label: (ctx) => `${ctx.label}: ${ctx.raw} (${((ctx.raw / ctx.dataset.data.reduce((a, b) => a + b, 0)) * 100).toFixed(1)}%)` } } + } + } + }); + } + } +}); + +Vue.component('wd-company-chart', { + props: { data: { type: Array, required: true } }, + template: '
    ', + data() { return { chart: null }; }, + mounted() { this.renderChart(); }, + watch: { data: { handler() { this.renderChart(); }, deep: true } }, + methods: { + renderChart() { + if (this.chart) this.chart.destroy(); + if (!this.data || this.data.length === 0) return; + this.chart = new Chart(this.$refs.chart.getContext('2d'), { + type: 'bar', + data: { + labels: this.data.map(d => d.company), + datasets: [ + { label: 'Abgeschlossen', data: this.data.map(d => d.completed), backgroundColor: '#10b981', borderRadius: 4 }, + { label: 'In Bearbeitung', data: this.data.map(d => d.pending), backgroundColor: '#f59e0b', borderRadius: 4 }, + { label: 'Probleme', data: this.data.map(d => d.issues), backgroundColor: '#ef4444', borderRadius: 4 } + ] + }, + options: { + responsive: true, maintainAspectRatio: false, indexAxis: 'y', + plugins: { legend: { position: 'top', labels: { usePointStyle: true } } }, + scales: { x: { stacked: true, grid: { display: false } }, y: { stacked: true, grid: { display: false } } } + } + }); + } + } +}); + +Vue.component('wd-trends-chart', { + props: { data: { type: Array, required: true } }, + template: '
    ', + data() { return { chart: null }; }, + mounted() { this.renderChart(); }, + watch: { data: { handler() { this.renderChart(); }, deep: true } }, + methods: { + renderChart() { + if (this.chart) this.chart.destroy(); + if (!this.data || this.data.length === 0) return; + this.chart = new Chart(this.$refs.chart.getContext('2d'), { + type: 'line', + data: { + labels: this.data.map(d => d.date), + datasets: [ + { label: 'Erstellt', data: this.data.map(d => d.created), borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.1)', fill: true, tension: 0.3, pointRadius: 4, pointHoverRadius: 6 }, + { label: 'Abgeschlossen', data: this.data.map(d => d.completed), borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.1)', fill: true, tension: 0.3, pointRadius: 4, pointHoverRadius: 6 } + ] + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { position: 'top', labels: { usePointStyle: true } } }, + scales: { + x: { type: 'time', time: { unit: 'day', displayFormats: { day: 'DD.MM' } }, grid: { display: false } }, + y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' } } + } + } + }); + } + } +}); + +Vue.component('wd-intervention-rates', { + props: { data: { type: Array, required: true } }, + template: ` +
    +
    + {{ item.company }} +
    +
    +
    + {{ item.rate }}% +
    {{ item.total }} Ges.
    +
    +
    Keine Daten verfügbar
    +
    + `, + methods: { + getRateClass(rate) { return rate >= 20 ? 'rate--danger' : rate >= 10 ? 'rate--warning' : 'rate--success'; } + } +}); + +Vue.component('workorder-dashboard', { + template: ` +
    +
    +
    + + + + + + +
    +
    +

    Dashboard wird geladen...

    +
    + +

    Bitte wählen Sie einen Mandanten aus

    +

    Wählen Sie oben einen Mandanten, um das Dashboard anzuzeigen.

    +
    +
    +
    + + + + + +
    +
    +
    +

    Status-Verteilung

    + +
    +
    +

    Firmen-Performance

    + +
    +
    +
    +

    Zeitlicher Verlauf

    + +
    +
    +

    Firma → Status → Kampagne (Detailansicht)

    +
    + + + + + + + + + + + +
    FirmaStatusAnzahlKampagnen-Aufschlüsselung
    {{ row.company }}{{ row.statusLabel }}{{ row.count }}{{ c.name }}: {{ c.count }}
    Keine Daten verfügbar
    +
    +
    +
    +
    +

    Problemquote pro Firma

    + +
    +
    +

    Häufigste Status-Übergänge

    +
    +
    + {{ t.transition }} + {{ t.count }} +
    +
    Keine Daten verfügbar
    +
    +
    +
    +
    +
    + `, + data() { + return { + selectedTenant: '', dateRange: null, selectedCompany: '', selectedStatus: '', selectedCampaign: '', + filterOptions: { tenants: [], companies: [], statuses: [], campaigns: [] }, + kpis: null, statusDistribution: [], companyPerformance: [], timeTrends: [], + companyStatusCampaign: [], interventionRates: [], statusTransitions: [], + loading: false + }; + }, + async mounted() { + const today = new Date(); + today.setHours(23, 59, 59, 0); + const threeMonthsAgo = new Date(today); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + threeMonthsAgo.setHours(0, 0, 0, 0); + this.dateRange = { from: Math.floor(threeMonthsAgo.getTime() / 1000), to: Math.floor(today.getTime() / 1000) }; + await this.fetchFilterOptions(); + }, + methods: { + async fetchFilterOptions() { + try { + const response = await axios.get(`${window.TT_CONFIG['BASE_URL']}/getFilterOptions`); + this.filterOptions.tenants = response.data.tenants; + this.filterOptions.companies = response.data.companies; + this.filterOptions.statuses = response.data.statuses; + } catch (error) { console.error('Error fetching filter options:', error); } + }, + async onTenantChange(tenantId) { + this.selectedTenant = tenantId; + this.selectedCampaign = ''; + if (tenantId) { + try { + const response = await axios.post(`${window.TT_CONFIG['BASE_URL']}/getCampaignsForTenant`, { tenantId }); + this.filterOptions.campaigns = response.data; + } catch (error) { console.error('Error fetching campaigns:', error); } + await this.fetchDashboardData(); + } + }, + onDateRangeChange(range) { this.dateRange = range; this.onFilterChange(); }, + onFilterChange() { if (this.selectedTenant) this.fetchDashboardData(); }, + async fetchDashboardData() { + if (!this.selectedTenant) return; + this.loading = true; + try { + const response = await axios.post(`${window.TT_CONFIG['BASE_URL']}/getDashboardData`, { + tenantId: this.selectedTenant, + dateFrom: this.dateRange?.from || null, + dateTo: this.dateRange?.to || null, + companyIds: this.selectedCompany ? [this.selectedCompany] : [], + statuses: this.selectedStatus ? [this.selectedStatus] : [], + campaignIds: this.selectedCampaign ? [this.selectedCampaign] : [] + }); + this.kpis = response.data.kpis; + this.statusDistribution = response.data.statusDistribution; + this.companyPerformance = response.data.companyPerformance; + this.timeTrends = response.data.timeTrends; + this.companyStatusCampaign = response.data.companyStatusCampaign; + this.interventionRates = response.data.interventionRates; + this.statusTransitions = response.data.statusTransitions; + } catch (error) { + console.error('Error fetching dashboard data:', error); + alert('Fehler beim Laden der Dashboard-Daten.'); + } finally { this.loading = false; } + } + } +});