feat: FieldReportsController API #18

Merged
jmiller merged 1 commits from dev into main 2026-06-20 21:34:49 +00:00
@@ -0,0 +1,122 @@
<?php
namespace Moko\Component\MokoSuiteField\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\Database\DatabaseInterface;
/**
* Field Service Reports API.
*
* GET /reports/tech-performance — Technician performance metrics
* GET /reports/revenue — Revenue by trade/period
* GET /reports/parts-usage — Parts consumption summary
* GET /reports/sla-compliance — SLA compliance rates
*/
class FieldReportsController extends BaseController
{
private function requireAuth(): void
{
$user = Factory::getApplication()->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();
}
}