From 52c7684c6ad72786e514f52777f14e727ff78a11 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 20 Jun 2026 15:50:54 -0500 Subject: [PATCH] =?UTF-8?q?Add=20FieldReportsController=20API=20=E2=80=94?= =?UTF-8?q?=20tech=20performance,=20revenue=20by=20trade,=20parts=20usage,?= =?UTF-8?q?=20SLA=20compliance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Controller/FieldReportsController.php | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 source/packages/com_mokosuitefield/api/src/Controller/FieldReportsController.php diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldReportsController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldReportsController.php new file mode 100644 index 0000000..e987004 --- /dev/null +++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldReportsController.php @@ -0,0 +1,122 @@ +getIdentity(); + if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise('core.manage', 'com_mokosuitefield'))) { + http_response_code(403); + echo json_encode(['error' => 'Access denied']); + Factory::getApplication()->close(); + } + } + + public function techPerformance(): void + { + $this->requireAuth(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + $from = $input->getString('from', date('Y-m-01')); + $to = $input->getString('to', date('Y-m-d')); + + $db->setQuery($db->getQuery(true) + ->select('t.id, cd.name AS tech_name, t.trade') + ->select('COUNT(wo.id) AS total_jobs') + ->select('SUM(CASE WHEN wo.status = ' . $db->quote('completed') . ' THEN 1 ELSE 0 END) AS completed') + ->select('COALESCE(AVG(TIMESTAMPDIFF(MINUTE, wo.dispatched_at, wo.completed_at)), 0) AS avg_resolution_min') + ->select('COALESCE(SUM(te.hours), 0) AS total_hours') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_work_orders', 'wo') . ' ON wo.technician_id = t.id AND wo.scheduled_date BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)) + ->join('LEFT', $db->quoteName('#__mokosuitefield_time_entries', 'te') . ' ON te.wo_id = wo.id') + ->group('t.id') + ->order('completed DESC')); + + $techs = $db->loadObjectList() ?: []; + + foreach ($techs as &$tech) { + $tech->completion_rate = (int) $tech->total_jobs > 0 + ? round((int) $tech->completed / (int) $tech->total_jobs * 100, 1) : 0; + } + + $this->sendJson($techs); + } + + public function revenueByTrade(): void + { + $this->requireAuth(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + $from = $input->getString('from', date('Y-m-01')); + $to = $input->getString('to', date('Y-m-d')); + + $db->setQuery($db->getQuery(true) + ->select('wo.trade') + ->select('COUNT(wo.id) AS job_count') + ->select('COALESCE(SUM(i.total), 0) AS revenue') + ->select('COALESCE(AVG(i.total), 0) AS avg_invoice') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__mokosuite_crm_invoices', 'i') . ' ON i.id = wo.invoice_id') + ->where('wo.scheduled_date BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)) + ->where($db->quoteName('wo.status') . ' = ' . $db->quote('completed')) + ->group('wo.trade') + ->order('revenue DESC')); + + $this->sendJson($db->loadObjectList() ?: []); + } + + public function partsUsage(): void + { + $this->requireAuth(); + $parts = \Moko\Plugin\System\MokoSuiteField\Helper\PartsHelper::getCommonParts('', 30); + $lowStock = \Moko\Plugin\System\MokoSuiteField\Helper\PartsHelper::getLowStockParts(20); + + $this->sendJson(['top_used' => $parts, 'low_stock' => $lowStock]); + } + + public function slaCompliance(): void + { + $this->requireAuth(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + $from = $input->getString('from', date('Y-m-01')); + $to = $input->getString('to', date('Y-m-d')); + $slaHours = $input->getInt('sla_hours', 24); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total') + ->select('SUM(CASE WHEN TIMESTAMPDIFF(HOUR, wo.created, wo.completed_at) <= ' . (int) $slaHours . ' THEN 1 ELSE 0 END) AS within_sla') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->where($db->quoteName('wo.status') . ' = ' . $db->quote('completed')) + ->where('wo.scheduled_date BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to))); + + $stats = $db->loadObject() ?: (object) ['total' => 0, 'within_sla' => 0]; + $stats->sla_pct = (int) $stats->total > 0 ? round((int) $stats->within_sla / (int) $stats->total * 100, 1) : 0; + $stats->sla_target_hours = $slaHours; + + $this->sendJson($stats); + } + + private function sendJson(mixed $data): void + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + Factory::getApplication()->close(); + } +} -- 2.52.0