Merge branch 'Workorder/improve-and-add-dashboard' into 'master'
improve and add dashboard See merge request fronk/thetool!2059
This commit is contained in:
@@ -141,9 +141,9 @@
|
||||
<?php if($me->is(["Admin","netowner","pipeplanner","pipeplanner"]) && $me->is("employee")): ?><li><a href="<?=self::getUrl("FiberPlanDispatcher")?>"><i class="fas fa-building-circle-arrow-right text-info"></i> Verteiler und Schächte</a></li><?php endif; ?>
|
||||
<?php if($me->is(["Admin","netowner","lineplanner","lineworker"]) && $me->is("employee")): ?><li><a href="<?=self::getUrl("FiberPlanPipe")?>"><i class="fas fa-pipe text-info pl-1"></i> Rohrverzeichnis</a></li><?php endif; ?>
|
||||
<?php if($me->is(["Admin","netowner","lineplanner","lineworker"]) && $me->is("employee")): ?><li><a href="<?=self::getUrl("FiberPlanCable")?>"><i class="fa-solid fa-timeline text-info "></i> Kabelverzeichnis</a></li><?php endif; ?>
|
||||
<!-- add a new line Arbeitsaufträge for RMLCompany, add a new line Arbeitsaufträge-Management for RMLAdmin -->
|
||||
<?php if($me->can("RMLCompany")): ?><li><a href="<?=self::getUrl("WorkorderCompany")?>"><i class="far fa-fw fa-clipboard-question text-info"></i> Arbeitsaufträge</a></li><?php endif; ?>
|
||||
<?php if($me->can("RMLAdmin")): ?><li><a href="<?=self::getUrl("WorkorderAdmin")?>"><i class="far fa-fw fa-clipboard-question text-info"></i> Arbeitsaufträge-Management</a></li><?php endif; ?>
|
||||
<?php if($me->can("RMLAdmin")): ?><li><a href="<?=self::getUrl("WorkorderDashboard")?>"><i class="far fa-fw fa-chart-line text-info"></i> Arbeitsaufträge-Dashboard</a></li><?php endif; ?>
|
||||
<?php if($me->can("WorkorderMph")): ?><li><a href="<?=self::getUrl("WorkorderMphCompany")?>"><i class="far fa-fw fa-buildings text-info"></i> MPH Arbeitsaufträge</a></li><?php endif; ?>
|
||||
<?php if($me->can("WorkorderMphAdmin")): ?><li><a href="<?=self::getUrl("WorkorderMphAdmin")?>"><i class="far fa-fw fa-buildings text-info"></i> MPH Arbeitsaufträge Verwaltung</a></li><?php endif; ?>
|
||||
</ul>
|
||||
|
||||
@@ -54,6 +54,7 @@ class WorkorderAdminController extends WorkorderBaseController
|
||||
public function indexAction()
|
||||
{
|
||||
$this->createWorkordersFromPreorders();
|
||||
$this->autoCompleteDocumentedWorkorders();
|
||||
$this->archiveWorkorders();
|
||||
parent::indexAction();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
295
application/WorkorderDashboard/WorkorderDashboardController.php
Normal file
295
application/WorkorderDashboard/WorkorderDashboardController.php
Normal file
@@ -0,0 +1,295 @@
|
||||
<?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' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
36
db/migrations/20260128074700_add_auto_complete_filter.php
Normal file
36
db/migrations/20260128074700_add_auto_complete_filter.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AddAutoCompleteFilter extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if ($this->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();
|
||||
}
|
||||
}
|
||||
135
public/js/pages/WorkorderDashboard/WorkorderDashboard.css
Normal file
135
public/js/pages/WorkorderDashboard/WorkorderDashboard.css
Normal file
@@ -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; }
|
||||
}
|
||||
276
public/js/pages/WorkorderDashboard/WorkorderDashboard.js
Normal file
276
public/js/pages/WorkorderDashboard/WorkorderDashboard.js
Normal file
@@ -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: `
|
||||
<div class="kpi-card" :class="'kpi-card--' + color">
|
||||
<div class="kpi-card__icon"><i :class="icon"></i></div>
|
||||
<div class="kpi-card__content">
|
||||
<div class="kpi-card__value">{{ value }}{{ suffix }}</div>
|
||||
<div class="kpi-card__title">{{ title }}</div>
|
||||
<div v-if="subtitle" class="kpi-card__subtitle">{{ subtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
Vue.component('wd-status-chart', {
|
||||
props: { data: { type: Array, required: true } },
|
||||
template: '<div class="chart-wrapper"><canvas ref="chart"></canvas></div>',
|
||||
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: '<div class="chart-wrapper chart-wrapper--large"><canvas ref="chart"></canvas></div>',
|
||||
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: '<div class="chart-wrapper chart-wrapper--wide"><canvas ref="chart"></canvas></div>',
|
||||
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: `
|
||||
<div class="intervention-rates">
|
||||
<div v-for="item in data" :key="item.company" class="intervention-rate-item">
|
||||
<span class="company-name" :title="item.company">{{ item.company }}</span>
|
||||
<div class="intervention-rate-item__bar">
|
||||
<div class="bar-fill" :style="{ width: Math.min(item.rate * 2, 100) + '%' }" :class="getRateClass(item.rate)"></div>
|
||||
</div>
|
||||
<span class="rate-value" :class="getRateClass(item.rate)">{{ item.rate }}%</span>
|
||||
<div class="intervention-rate-item__details"><span>{{ item.total }} Ges.</span></div>
|
||||
</div>
|
||||
<div v-if="data.length === 0" class="no-data">Keine Daten verfügbar</div>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
getRateClass(rate) { return rate >= 20 ? 'rate--danger' : rate >= 10 ? 'rate--warning' : 'rate--success'; }
|
||||
}
|
||||
});
|
||||
|
||||
Vue.component('workorder-dashboard', {
|
||||
template: `
|
||||
<div class="tt-scope workorder-dashboard">
|
||||
<div class="filter-bar">
|
||||
<div class="filter-bar__inner">
|
||||
<tt-select sm label="Mandant" :value="selectedTenant" :options="[{value: '', text: 'Bitte wählen...'}, ...filterOptions.tenants]" @input="onTenantChange" />
|
||||
<tt-date-picker sm label="Zeitraum" :value="dateRange" :date-range="true" :time-picker="false" @input="onDateRangeChange" />
|
||||
<tt-select sm label="Firma" :value="selectedCompany" :options="[{value: '', text: 'Alle'}, ...filterOptions.companies]" @input="selectedCompany = $event; onFilterChange()" />
|
||||
<tt-select sm label="Status" :value="selectedStatus" :options="[{value: '', text: 'Alle'}, ...filterOptions.statuses]" @input="selectedStatus = $event; onFilterChange()" />
|
||||
<tt-select sm label="Kampagne" :value="selectedCampaign" :options="[{value: '', text: 'Alle'}, ...filterOptions.campaigns]" @input="selectedCampaign = $event; onFilterChange()" />
|
||||
<button class="btn btn-primary btn-sm refresh-btn" @click="fetchDashboardData" :disabled="!selectedTenant || loading">
|
||||
<i class="fas fa-sync-alt" :class="{ 'fa-spin': loading }"></i> Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="loading-overlay"><div class="spinner"></div><p>Dashboard wird geladen...</p></div>
|
||||
<div v-else-if="!selectedTenant" class="empty-state">
|
||||
<i class="fas fa-building"></i>
|
||||
<h3>Bitte wählen Sie einen Mandanten aus</h3>
|
||||
<p>Wählen Sie oben einen Mandanten, um das Dashboard anzuzeigen.</p>
|
||||
</div>
|
||||
<div v-else class="dashboard-content">
|
||||
<div v-if="kpis" class="kpi-row">
|
||||
<wd-kpi-card title="Gesamt" :value="kpis.total || 0" icon="fas fa-clipboard-list" color="primary" />
|
||||
<wd-kpi-card title="Abgeschlossen" :value="kpis.completed || 0" icon="fas fa-check-circle" color="success" />
|
||||
<wd-kpi-card title="In Bearbeitung" :value="kpis.pending || 0" icon="fas fa-clock" color="warning" />
|
||||
<wd-kpi-card title="Problemrate" :value="kpis.interventionRate || 0" suffix="%" icon="fas fa-exclamation-triangle" color="danger" :subtitle="(kpis.issues || 0) + ' Probleme'" />
|
||||
<wd-kpi-card title="Ø Bearbeitungszeit" :value="kpis.avgCompletionDays || '-'" :suffix="kpis.avgCompletionDays ? ' Tage' : ''" icon="fas fa-hourglass-half" color="info" />
|
||||
</div>
|
||||
<div class="charts-row">
|
||||
<div class="chart-card">
|
||||
<h3 class="chart-card__title"><i class="fas fa-chart-pie"></i> Status-Verteilung</h3>
|
||||
<wd-status-chart :data="statusDistribution" />
|
||||
</div>
|
||||
<div class="chart-card chart-card--wide">
|
||||
<h3 class="chart-card__title"><i class="fas fa-building"></i> Firmen-Performance</h3>
|
||||
<wd-company-chart :data="companyPerformance" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card chart-card--full">
|
||||
<h3 class="chart-card__title"><i class="fas fa-chart-line"></i> Zeitlicher Verlauf</h3>
|
||||
<wd-trends-chart :data="timeTrends" />
|
||||
</div>
|
||||
<div class="chart-card chart-card--full">
|
||||
<h3 class="chart-card__title"><i class="fas fa-table"></i> Firma → Status → Kampagne (Detailansicht)</h3>
|
||||
<div class="detail-table-wrapper">
|
||||
<table class="detail-table">
|
||||
<thead><tr><th>Firma</th><th>Status</th><th>Anzahl</th><th>Kampagnen-Aufschlüsselung</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in companyStatusCampaign" :key="index">
|
||||
<td class="company-cell">{{ row.company }}</td>
|
||||
<td><span class="status-badge" :class="'status-badge--' + row.status">{{ row.statusLabel }}</span></td>
|
||||
<td class="count-cell">{{ row.count }}</td>
|
||||
<td class="campaigns-cell"><span v-for="(c, i) in row.campaigns" :key="i" class="campaign-tag">{{ c.name }}: {{ c.count }}</span></td>
|
||||
</tr>
|
||||
<tr v-if="companyStatusCampaign.length === 0"><td colspan="4" class="no-data">Keine Daten verfügbar</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analytics-row">
|
||||
<div class="chart-card">
|
||||
<h3 class="chart-card__title"><i class="fas fa-exclamation-circle"></i> Problemquote pro Firma</h3>
|
||||
<wd-intervention-rates :data="interventionRates" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3 class="chart-card__title"><i class="fas fa-exchange-alt"></i> Häufigste Status-Übergänge</h3>
|
||||
<div class="transitions-list">
|
||||
<div v-for="(t, index) in statusTransitions" :key="index" class="transition-item">
|
||||
<span class="transition-label">{{ t.transition }}</span>
|
||||
<span class="transition-count">{{ t.count }}</span>
|
||||
</div>
|
||||
<div v-if="statusTransitions.length === 0" class="no-data">Keine Daten verfügbar</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
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; }
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user