feat: initial scaffold with component, system plugin, and webservices plugin
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 15s

This commit is contained in:
2026-06-27 20:17:43 +00:00
parent 9b5060f772
commit 5a498cf3f6
106 changed files with 1057 additions and 4706 deletions
+7
View File
@@ -0,0 +1,7 @@
.claude/
.mcp.json
TODO.md
*.min.css
*.min.js
vendor/
node_modules/
+14 -9
View File
@@ -1,12 +1,17 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
Authored-by: Moko Consulting
-->
# Changelog
## [01.01.00] - 2026-06-12
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- Initial scaffold: field service management for MokoSuite
- 12 database tables for technicians, work orders, service agreements, equipment, vehicles, estimates
- 7 helpers: Dispatch, WorkOrder, ServiceAgreement, Equipment, Estimate, TruckStock, Vehicle
- Admin views: Dashboard, Work Orders, Technicians, Service Agreements, Equipment, Dispatch, Vehicles
- Site views: Tech Mobile (tablet), Book Service (public form)
- API controller with 6 endpoints
- Task scheduler: service reminders, agreement renewals, equipment warranty, truck stock reorder
- Joomla 6 architecture (PHP 8.3+)
- **Initial scaffold** — Package structure with component, system plugin, and webservices plugin
+25
View File
@@ -0,0 +1,25 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
Authored-by: Moko Consulting
-->
# MokoSuiteField
Field service management for contractors — a Layer 2 vertical for the MokoSuite platform.
## Structure
- `source/` — Joomla extension source (package, component, plugins)
- All work happens on the `dev` branch; never commit directly to `main`
- Use conventional commits (`feat:`, `fix:`, `chore:`, etc.)
## Build
Packaged via MokoCLI. Run `mokocli build` from the repo root.
## Standards
- PHP 8.3+ / Joomla 6 architecture
- `$this->getDatabase()` — never use `Factory::getDbo()`
- Namespace root: `Moko\Component\MokoSuiteField` (component), `Moko\Plugin\System\MokoSuiteField` (system plugin), `Moko\Plugin\WebServices\MokoSuiteField` (API plugin)
+25 -1
View File
@@ -1,3 +1,27 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
Authored-by: Moko Consulting
-->
# MokoSuiteField
MokoSuite Field Service - dispatch, work orders, scheduling, mobile tech, plumbing, electrical, HVAC, service agreements. Layer 2 add-on for MokoSuite (requires CRM).
Field service management for contractors — a MokoSuite Layer 2 vertical extension for Joomla.
## Overview
MokoSuiteField provides work order management, technician dispatch, equipment tracking, parts inventory, checklists, and preventive maintenance agreements for field service businesses.
## Requirements
- Joomla 6.x
- PHP 8.3+
- MokoSuite (base platform)
## Installation
Install via the MokoSuite package manager or upload `pkg_mokosuitefield.zip` through Joomla's extension installer.
## License
GPL-3.0-or-later
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
Authored-by: Moko Consulting
-->
<access component="com_mokosuitefield">
<section name="component">
<action name="core.admin" title="JACTION_ADMIN" />
<action name="core.options" title="JACTION_OPTIONS" />
<action name="core.manage" title="JACTION_MANAGE" />
<action name="core.create" title="JACTION_CREATE" />
<action name="core.delete" title="JACTION_DELETE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="core.edit.state" title="JACTION_EDITSTATE" />
</section>
</access>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
Authored-by: Moko Consulting
-->
<extension type="component" method="upgrade">
<name>com_mokosuitefield</name>
<version>0.1.0</version>
<creationDate>2026-06-27</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting</copyright>
<license>GPL-3.0-or-later</license>
<description>COM_MOKOSUITEFIELD_DESCRIPTION</description>
<namespace path="src">Moko\Component\MokoSuiteField</namespace>
<administration>
<menu>COM_MOKOSUITEFIELD</menu>
<submenu>
<menu link="option=com_mokosuitefield&amp;view=fielddashboard" view="fielddashboard" img="icon-home">COM_MOKOSUITEFIELD_MENU_DASHBOARD</menu>
<menu link="option=com_mokosuitefield&amp;view=fieldworkorders" view="fieldworkorders" img="icon-file-2">COM_MOKOSUITEFIELD_MENU_WORKORDERS</menu>
<menu link="option=com_mokosuitefield&amp;view=fieldtechnicians" view="fieldtechnicians" img="icon-users">COM_MOKOSUITEFIELD_MENU_TECHNICIANS</menu>
<menu link="option=com_mokosuitefield&amp;view=fieldequipment" view="fieldequipment" img="icon-cogs">COM_MOKOSUITEFIELD_MENU_EQUIPMENT</menu>
<menu link="option=com_mokosuitefield&amp;view=fieldparts" view="fieldparts" img="icon-cube">COM_MOKOSUITEFIELD_MENU_PARTS</menu>
<menu link="option=com_mokosuitefield&amp;view=fieldchecklists" view="fieldchecklists" img="icon-checklist">COM_MOKOSUITEFIELD_MENU_CHECKLISTS</menu>
<menu link="option=com_mokosuitefield&amp;view=fieldagreements" view="fieldagreements" img="icon-contract">COM_MOKOSUITEFIELD_MENU_AGREEMENTS</menu>
<menu link="option=com_mokosuitefield&amp;view=fielddispatches" view="fielddispatches" img="icon-location">COM_MOKOSUITEFIELD_MENU_DISPATCHES</menu>
</submenu>
<files folder=".">
<folder>src</folder>
<folder>tmpl</folder>
<folder>services</folder>
<folder>language</folder>
<filename>access.xml</filename>
<filename>config.xml</filename>
</files>
</administration>
<languages folder="language">
<language tag="en-GB">en-GB/com_mokosuitefield.ini</language>
<language tag="en-GB">en-GB/com_mokosuitefield.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
Authored-by: Moko Consulting
-->
<config>
<fieldset name="permissions" label="JCONFIG_PERMISSIONS_LABEL" description="JCONFIG_PERMISSIONS_DESC">
<field
name="rules"
type="rules"
label="JCONFIG_PERMISSIONS_LABEL"
validate="rules"
filter="rules"
component="com_mokosuitefield"
section="component"
/>
</fieldset>
</config>
@@ -0,0 +1,14 @@
; Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
; SPDX-License-Identifier: GPL-3.0-or-later
; Authored-by: Moko Consulting
COM_MOKOSUITEFIELD="MokoSuite Field"
COM_MOKOSUITEFIELD_DESCRIPTION="Field service management for contractors."
COM_MOKOSUITEFIELD_MENU_DASHBOARD="Dashboard"
COM_MOKOSUITEFIELD_MENU_WORKORDERS="Work Orders"
COM_MOKOSUITEFIELD_MENU_TECHNICIANS="Technicians"
COM_MOKOSUITEFIELD_MENU_EQUIPMENT="Equipment"
COM_MOKOSUITEFIELD_MENU_PARTS="Parts"
COM_MOKOSUITEFIELD_MENU_CHECKLISTS="Checklists"
COM_MOKOSUITEFIELD_MENU_AGREEMENTS="PM Agreements"
COM_MOKOSUITEFIELD_MENU_DISPATCHES="Dispatches"
@@ -0,0 +1,14 @@
; Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
; SPDX-License-Identifier: GPL-3.0-or-later
; Authored-by: Moko Consulting
COM_MOKOSUITEFIELD="MokoSuite Field"
COM_MOKOSUITEFIELD_DESCRIPTION="Field service management for contractors."
COM_MOKOSUITEFIELD_MENU_DASHBOARD="Dashboard"
COM_MOKOSUITEFIELD_MENU_WORKORDERS="Work Orders"
COM_MOKOSUITEFIELD_MENU_TECHNICIANS="Technicians"
COM_MOKOSUITEFIELD_MENU_EQUIPMENT="Equipment"
COM_MOKOSUITEFIELD_MENU_PARTS="Parts"
COM_MOKOSUITEFIELD_MENU_CHECKLISTS="Checklists"
COM_MOKOSUITEFIELD_MENU_AGREEMENTS="PM Agreements"
COM_MOKOSUITEFIELD_MENU_DISPATCHES="Dispatches"
@@ -0,0 +1,41 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
\defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\Extension\MVCComponent;
use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
use Joomla\CMS\Extension\Service\Provider\RouterFactory;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\Component\Router\RouterFactoryInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoSuiteField'));
$container->registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoSuiteField'));
$container->registerServiceProvider(new RouterFactory('\\Moko\\Component\\MokoSuiteField'));
$container->set(
ComponentInterface::class,
function (Container $container) {
$component = new MVCComponent();
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
$component->setDispatcherFactory($container->get(ComponentDispatcherFactoryInterface::class));
$component->setRouterFactory($container->get(RouterFactoryInterface::class));
return $component;
}
);
}
};
@@ -0,0 +1,18 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
namespace Moko\Component\MokoSuiteField\Administrator\Controller;
use Joomla\CMS\MVC\Controller\BaseController;
\defined('_JEXEC') or die;
class DisplayController extends BaseController
{
protected $default_view = 'fielddashboard';
}
@@ -0,0 +1,24 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
namespace Moko\Component\MokoSuiteField\Administrator\View\FieldAgreements;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
\defined('_JEXEC') or die;
class HtmlView extends BaseHtmlView
{
public function display($tpl = null): void
{
ToolbarHelper::title('MokoSuite Field — PM Agreements', 'contract');
parent::display($tpl);
}
}
@@ -0,0 +1,24 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
namespace Moko\Component\MokoSuiteField\Administrator\View\FieldChecklists;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
\defined('_JEXEC') or die;
class HtmlView extends BaseHtmlView
{
public function display($tpl = null): void
{
ToolbarHelper::title('MokoSuite Field — Checklists', 'checklist');
parent::display($tpl);
}
}
@@ -0,0 +1,24 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
namespace Moko\Component\MokoSuiteField\Administrator\View\FieldDashboard;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
\defined('_JEXEC') or die;
class HtmlView extends BaseHtmlView
{
public function display($tpl = null): void
{
ToolbarHelper::title('MokoSuite Field — Dashboard', 'home');
parent::display($tpl);
}
}
@@ -0,0 +1,24 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
namespace Moko\Component\MokoSuiteField\Administrator\View\FieldDispatches;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
\defined('_JEXEC') or die;
class HtmlView extends BaseHtmlView
{
public function display($tpl = null): void
{
ToolbarHelper::title('MokoSuite Field — Dispatches', 'location');
parent::display($tpl);
}
}
@@ -0,0 +1,24 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
namespace Moko\Component\MokoSuiteField\Administrator\View\FieldEquipment;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
\defined('_JEXEC') or die;
class HtmlView extends BaseHtmlView
{
public function display($tpl = null): void
{
ToolbarHelper::title('MokoSuite Field — Equipment', 'cogs');
parent::display($tpl);
}
}
@@ -0,0 +1,24 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
namespace Moko\Component\MokoSuiteField\Administrator\View\FieldParts;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
\defined('_JEXEC') or die;
class HtmlView extends BaseHtmlView
{
public function display($tpl = null): void
{
ToolbarHelper::title('MokoSuite Field — Parts', 'cube');
parent::display($tpl);
}
}
@@ -0,0 +1,24 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
namespace Moko\Component\MokoSuiteField\Administrator\View\FieldTechnicians;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
\defined('_JEXEC') or die;
class HtmlView extends BaseHtmlView
{
public function display($tpl = null): void
{
ToolbarHelper::title('MokoSuite Field — Technicians', 'users');
parent::display($tpl);
}
}
@@ -0,0 +1,24 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
namespace Moko\Component\MokoSuiteField\Administrator\View\FieldWorkorders;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
\defined('_JEXEC') or die;
class HtmlView extends BaseHtmlView
{
public function display($tpl = null): void
{
ToolbarHelper::title('MokoSuite Field — Work Orders', 'file-2');
parent::display($tpl);
}
}
@@ -0,0 +1,13 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
\defined('_JEXEC') or die;
?>
<div class="mokosuitefield-agreements">
<h2>MokoSuite Field — PM Agreements</h2>
</div>
@@ -0,0 +1,13 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
\defined('_JEXEC') or die;
?>
<div class="mokosuitefield-checklists">
<h2>MokoSuite Field — Checklists</h2>
</div>
@@ -0,0 +1,13 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
\defined('_JEXEC') or die;
?>
<div class="mokosuitefield-dashboard">
<h2>MokoSuite Field — Dashboard</h2>
</div>
@@ -0,0 +1,13 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
\defined('_JEXEC') or die;
?>
<div class="mokosuitefield-dispatches">
<h2>MokoSuite Field — Dispatches</h2>
</div>
@@ -0,0 +1,13 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
\defined('_JEXEC') or die;
?>
<div class="mokosuitefield-equipment">
<h2>MokoSuite Field — Equipment</h2>
</div>
@@ -0,0 +1,13 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
\defined('_JEXEC') or die;
?>
<div class="mokosuitefield-parts">
<h2>MokoSuite Field — Parts</h2>
</div>
@@ -0,0 +1,13 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
\defined('_JEXEC') or die;
?>
<div class="mokosuitefield-technicians">
<h2>MokoSuite Field — Technicians</h2>
</div>
@@ -0,0 +1,13 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
\defined('_JEXEC') or die;
?>
<div class="mokosuitefield-workorders">
<h2>MokoSuite Field — Work Orders</h2>
</div>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<access component="com_mokosuitefield">
<section name="component">
<action name="core.admin" title="JACTION_ADMIN" />
<action name="core.manage" title="JACTION_MANAGE" />
<action name="core.create" title="JACTION_CREATE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="field.dispatch" title="Dispatch Work Orders" />
<action name="field.estimates" title="Create Estimates" />
</section>
</access>
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<config>
<fieldset name="basic" label="Field Service Settings">
<field name="company_name" type="text" default="" label="Company Name" />
<field name="default_trade" type="list" default="general" label="Default Trade">
<option value="general">General</option>
<option value="plumbing">Plumbing</option>
<option value="electrical">Electrical</option>
<option value="hvac">HVAC</option>
</field>
<field name="wo_prefix" type="text" default="WO" label="Work Order Prefix" />
</fieldset>
<fieldset name="dispatch" label="Dispatch">
<field name="auto_dispatch" type="radio" default="0" label="Auto-Dispatch" class="btn-group btn-group-yesno"><option value="1">JYES</option><option value="0">JNO</option></field>
<field name="default_service_radius" type="number" default="30" label="Default Service Radius (miles)" />
</fieldset>
<fieldset name="billing" label="Billing">
<field name="default_labor_rate" type="number" default="125" step="0.01" label="Default Labor Rate ($/hr)" />
<field name="overtime_multiplier" type="number" default="1.5" step="0.1" label="Overtime Multiplier" />
<field name="travel_charge" type="number" default="0" step="0.01" label="Travel Charge ($)" />
</fieldset>
<fieldset name="permissions" label="Permissions">
<field name="rules" type="rules" component="com_mokosuitefield" section="component" />
</fieldset>
</config>
@@ -1,18 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\Extension\MVCComponent;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(ComponentInterface::class, function (Container $container) {
$component = new MVCComponent($container->get(ComponentDispatcherFactoryInterface::class));
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
return $component;
});
}
};
@@ -1,11 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
class DisplayController extends BaseController
{
protected $default_view = 'dashboard';
}
@@ -1,37 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class DispatchModel extends BaseDatabaseModel
{
public function getTodayBoard(): array
{
$db = $this->getDatabase();
$db->setQuery($db->getQuery(true)
->select('t.id AS tech_id, cd.name AS tech_name, t.trade, t.status AS tech_status')
->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.scheduled_date = CURDATE() AND wo.status NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ')) AS pending_jobs')
->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.scheduled_date = CURDATE() AND wo.status = ' . $db->quote('completed') . ') AS completed_jobs')
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->where($db->quoteName('t.status') . ' != ' . $db->quote('inactive'))
->order('cd.name ASC'));
return $db->loadObjectList() ?: [];
}
public function getGpsLog(int $techId, string $date = ''): array
{
$db = $this->getDatabase();
$date = $date ?: date('Y-m-d');
$db->setQuery($db->getQuery(true)
->select('*')
->from('#__mokosuitefield_dispatch_log')
->where('technician_id = ' . $techId)
->where('DATE(recorded_at) = ' . $db->quote($date))
->order('recorded_at ASC'));
return $db->loadObjectList() ?: [];
}
}
@@ -1,29 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class EquipmentModel extends BaseDatabaseModel
{
public function getItems(string $type = '', string $status = '', int $locationId = 0, string $search = '', int $limit = 50): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('eq.*, loc.name AS location_name, loc.address, cd.name AS customer_name')
->from($db->quoteName('#__mokosuitefield_equipment', 'eq'))
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = eq.location_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = eq.contact_id')
->order('eq.name ASC');
if ($type) $query->where($db->quoteName('eq.type') . ' = ' . $db->quote($type));
if ($status) $query->where($db->quoteName('eq.status') . ' = ' . $db->quote($status));
if ($locationId) $query->where('eq.location_id = ' . $locationId);
if ($search) $query->where('(' . $db->quoteName('eq.name') . ' LIKE ' . $db->quote('%' . $search . '%')
. ' OR ' . $db->quoteName('eq.serial_number') . ' LIKE ' . $db->quote('%' . $search . '%') . ')');
$db->setQuery($query, 0, $limit);
return $db->loadObjectList() ?: [];
}
}
@@ -1,28 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class EstimatesModel extends BaseDatabaseModel
{
public function getItems(string $status = '', string $search = '', int $techId = 0, int $limit = 50): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('e.*, cd.name AS customer_name, loc.address')
->from($db->quoteName('#__mokosuitefield_estimates', 'e'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id')
->order('e.created DESC');
if ($status) $query->where($db->quoteName('e.status') . ' = ' . $db->quote($status));
if ($techId) $query->where('e.technician_id = ' . $techId);
if ($search) $query->where('(' . $db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%')
. ' OR ' . $db->quoteName('e.estimate_number') . ' LIKE ' . $db->quote('%' . $search . '%') . ')');
$db->setQuery($query, 0, $limit);
return $db->loadObjectList() ?: [];
}
}
@@ -1,39 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class ServiceAgreementsModel extends BaseDatabaseModel
{
public function getItems(string $status = '', string $search = '', int $limit = 50): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('sa.*, cd.name AS customer_name, loc.address')
->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = sa.location_id')
->order('sa.end_date ASC');
if ($status) $query->where($db->quoteName('sa.status') . ' = ' . $db->quote($status));
if ($search) $query->where($db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%'));
$db->setQuery($query, 0, $limit);
return $db->loadObjectList() ?: [];
}
public function getExpiringSoon(int $days = 30): array
{
$db = $this->getDatabase();
$db->setQuery($db->getQuery(true)
->select('sa.*, cd.name AS customer_name')
->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id')
->where($db->quoteName('sa.status') . ' = ' . $db->quote('active'))
->where($db->quoteName('sa.end_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $days . ' DAY)')
->order('sa.end_date ASC'));
return $db->loadObjectList() ?: [];
}
}
@@ -1,27 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class TechniciansModel extends BaseDatabaseModel
{
public function getItems(string $status = '', string $trade = '', string $search = '', int $limit = 50): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('t.*, cd.name AS tech_name, cd.email_to, cd.telephone')
->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.status IN (' . $db->quote('dispatched') . ',' . $db->quote('in_progress') . ')) AS active_jobs')
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->order('cd.name ASC');
if ($status) $query->where($db->quoteName('t.status') . ' = ' . $db->quote($status));
if ($trade) $query->where($db->quoteName('t.trade') . ' = ' . $db->quote($trade));
if ($search) $query->where($db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%'));
$db->setQuery($query, 0, $limit);
return $db->loadObjectList() ?: [];
}
}
@@ -1,26 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class VehiclesModel extends BaseDatabaseModel
{
public function getItems(string $status = '', int $techId = 0, int $limit = 50): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('v.*, cd.name AS tech_name')
->from($db->quoteName('#__mokosuitefield_vehicles', 'v'))
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.technician_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->order('v.vehicle_name ASC');
if ($status) $query->where($db->quoteName('v.status') . ' = ' . $db->quote($status));
if ($techId) $query->where('v.technician_id = ' . $techId);
$db->setQuery($query, 0, $limit);
return $db->loadObjectList() ?: [];
}
}
@@ -1,62 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class WorkOrdersModel extends BaseDatabaseModel
{
public function getItems(string $status = '', string $trade = '', int $techId = 0, string $search = '', string $date = '', int $limit = 50, int $offset = 0): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('wo.*, cd.name AS customer_name, loc.address, loc.city, loc.state, loc.zip')
->select('t_cd.name AS tech_name')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id')
->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id')
->order('wo.scheduled_date DESC, wo.priority DESC');
if ($status) $query->where($db->quoteName('wo.status') . ' = ' . $db->quote($status));
if ($trade) $query->where($db->quoteName('wo.trade') . ' = ' . $db->quote($trade));
if ($techId) $query->where('wo.technician_id = ' . $techId);
if ($date) $query->where('wo.scheduled_date = ' . $db->quote($date));
if ($search) {
$query->where('(' . $db->quoteName('wo.wo_number') . ' LIKE ' . $db->quote('%' . $search . '%')
. ' OR ' . $db->quoteName('wo.description') . ' LIKE ' . $db->quote('%' . $search . '%')
. ' OR ' . $db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%') . ')');
}
$db->setQuery($query, $offset, $limit);
return $db->loadObjectList() ?: [];
}
public function getWorkOrder(int $id): ?object
{
$db = $this->getDatabase();
$db->setQuery($db->getQuery(true)
->select('wo.*, cd.name AS customer_name, t_cd.name AS tech_name')
->select('loc.address, loc.city, loc.state, loc.zip, loc.latitude, loc.longitude, loc.name AS location_name')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id')
->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id')
->where('wo.id = ' . (int) $id));
return $db->loadObject();
}
public function getStatusCounts(): object
{
$db = $this->getDatabase();
$db->setQuery($db->getQuery(true)
->select('status, COUNT(*) AS cnt')
->from('#__mokosuitefield_work_orders')
->group('status'));
$rows = $db->loadObjectList('status') ?: [];
return (object) array_map(fn($r) => (int) $r->cnt, (array) $rows);
}
}
@@ -1,40 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\View\Dashboard;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Database\DatabaseInterface;
class HtmlView extends BaseHtmlView
{
public object $stats;
public array $dispatchBoard = [];
public array $unassigned = [];
public array $urgent = [];
public function display($tpl = null): void
{
$this->stats = \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::getDashboardStats();
$this->dispatchBoard = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getDispatchBoard();
$this->unassigned = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getUnassigned();
$db = Factory::getContainer()->get(DatabaseInterface::class);
// Urgent/emergency jobs
$db->setQuery($db->getQuery(true)
->select('wo.*, cd.name AS customer_name, loc.address')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
->where($db->quoteName('wo.priority') . ' IN (' . $db->quote('emergency') . ',' . $db->quote('urgent') . ')')
->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ',' . $db->quote('invoiced') . ')')
->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ') ASC, wo.created ASC'), 0, 10);
$this->urgent = $db->loadObjectList() ?: [];
ToolbarHelper::title('MokoSuite Field Service', 'icon-wrench');
parent::display($tpl);
}
}
@@ -1,28 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\View\Dispatch;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
public array $board = [];
public array $unassigned = [];
public object $stats;
public string $date;
public function display($tpl = null): void
{
$this->date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d'));
$this->board = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getDispatchBoard($this->date);
$this->unassigned = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getUnassigned();
$this->stats = \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::getDashboardStats();
ToolbarHelper::title('Field Service - Dispatch Board', 'icon-map');
parent::display($tpl);
}
}
@@ -1,34 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\View\Equipment;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Database\DatabaseInterface;
class HtmlView extends BaseHtmlView
{
public array $equipment = [];
public array $serviceDue = [];
public function display($tpl = null): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('e.*, loc.address, loc.city, cd.name AS owner_name')
->from($db->quoteName('#__mokosuitefield_equipment', 'e'))
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
->order('e.equipment_type ASC, e.make ASC'));
$this->equipment = $db->loadObjectList() ?: [];
$this->serviceDue = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getDueForService(30);
ToolbarHelper::title('Field Service — Equipment', 'icon-cogs');
ToolbarHelper::addNew('equipment.add');
parent::display($tpl);
}
}
@@ -1,23 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\View\ServiceAgreements;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
public array $agreements = [];
public object $revenue;
public function display($tpl = null): void
{
$this->agreements = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getActiveAgreements();
$this->revenue = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getRevenueSummary();
ToolbarHelper::title('Field Service — Service Agreements', 'icon-file-contract');
ToolbarHelper::addNew('serviceagreements.add');
parent::display($tpl);
}
}
@@ -1,32 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\View\Technicians;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Database\DatabaseInterface;
class HtmlView extends BaseHtmlView
{
public array $technicians = [];
public function display($tpl = null): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('t.*, cd.name AS tech_name, cd.telephone, cd.email_to, v.vehicle_number')
->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.status = ' . $db->quote('completed') . ' AND MONTH(wo.actual_departure) = MONTH(NOW())) AS jobs_this_month')
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_vehicles', 'v') . ' ON v.id = t.vehicle_id')
->order('cd.name ASC'));
$this->technicians = $db->loadObjectList() ?: [];
ToolbarHelper::title('Field Service — Technicians', 'icon-users');
ToolbarHelper::addNew('technicians.add');
parent::display($tpl);
}
}
@@ -1,23 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\View\Vehicles;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
public array $vehicles = [];
public array $inspectionsDue = [];
public function display($tpl = null): void
{
$this->vehicles = \Moko\Plugin\System\MokoSuiteField\Helper\VehicleHelper::getFleet();
$this->inspectionsDue = \Moko\Plugin\System\MokoSuiteField\Helper\VehicleHelper::getInspectionsDue(30);
ToolbarHelper::title('Field Service — Vehicles', 'icon-truck');
ToolbarHelper::addNew('vehicles.add');
parent::display($tpl);
}
}
@@ -1,52 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\View\WorkOrders;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Database\DatabaseInterface;
class HtmlView extends BaseHtmlView
{
public array $orders = [];
public array $filters = [];
public function display($tpl = null): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$input = Factory::getApplication()->getInput();
$this->filters = [
'status' => $input->getString('filter_status', ''),
'trade' => $input->getString('filter_trade', ''),
'date' => $input->getString('filter_date', ''),
'search' => $input->getString('filter_search', ''),
];
$query = $db->getQuery(true)
->select('wo.*, cd.name AS customer_name, loc.address, loc.city, t_cd.name AS tech_name')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id')
->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id')
->order('wo.created DESC');
if ($this->filters['status']) $query->where($db->quoteName('wo.status') . ' = ' . $db->quote($this->filters['status']));
if ($this->filters['trade']) $query->where($db->quoteName('wo.trade') . ' = ' . $db->quote($this->filters['trade']));
if ($this->filters['date']) $query->where($db->quoteName('wo.scheduled_date') . ' = ' . $db->quote($this->filters['date']));
if ($this->filters['search']) {
$like = $db->quote('%' . $db->escape($this->filters['search'], true) . '%');
$query->where('(wo.wo_number LIKE ' . $like . ' OR cd.name LIKE ' . $like . ')');
}
$db->setQuery($query, 0, 100);
$this->orders = $db->loadObjectList() ?: [];
ToolbarHelper::title('Field Service — Work Orders', 'icon-wrench');
ToolbarHelper::addNew('workorders.add');
parent::display($tpl);
}
}
@@ -1,33 +0,0 @@
<?php
defined('_JEXEC') or die;
$s = $this->stats;
$board = $this->dispatchBoard;
$unassigned = $this->unassigned;
$urgent = $this->urgent;
?>
<div class="row g-3 mb-4">
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int) $s->total_today; ?></div><small>Today</small></div></div></div>
<div class="col-md-2"><div class="card shadow-sm border-danger"><div class="card-body text-center"><div class="fs-3 fw-bold text-danger"><?php echo (int) $s->urgent; ?></div><small>Urgent</small></div></div></div>
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-secondary"><?php echo (int) $s->unassigned; ?></div><small>Unassigned</small></div></div></div>
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-warning"><?php echo (int) $s->en_route; ?></div><small>En Route</small></div></div></div>
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-primary"><?php echo (int) $s->on_site; ?></div><small>On Site</small></div></div></div>
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-success"><?php echo (int) $s->completed; ?></div><small>Done</small></div></div></div>
</div>
<div class="row g-3">
<div class="col-lg-8"><div class="card shadow-sm"><div class="card-header"><h5 class="mb-0">Dispatch Board</h5></div><div class="card-body">
<?php foreach ($board as $tech) : ?>
<div class="mb-3 p-2 border rounded">
<div class="d-flex justify-content-between mb-1"><strong><?php echo $this->escape($tech->tech_name); ?></strong><span class="badge bg-secondary"><?php echo ucfirst($tech->trade); ?></span></div>
<?php if (!empty($tech->jobs)) : foreach ($tech->jobs as $job) : ?>
<div class="ms-3 small border-start ps-2 mb-1"><code><?php echo $this->escape($job->wo_number); ?></code> <?php echo $this->escape($job->customer_name ?? ''); ?> <span class="text-muted"><?php echo $this->escape($job->city ?? ''); ?></span></div>
<?php endforeach; else : ?><div class="ms-3 small text-muted">No jobs</div><?php endif; ?>
</div>
<?php endforeach; ?>
</div></div></div>
<div class="col-lg-4"><div class="card shadow-sm"><div class="card-header"><h5 class="mb-0">Unassigned (<?php echo count($unassigned); ?>)</h5></div><div class="card-body p-0">
<?php foreach ($unassigned as $u) : ?>
<div class="p-2 border-bottom"><strong class="small"><?php echo $this->escape($u->customer_name ?? ''); ?></strong><br><small class="text-muted"><?php echo $this->escape($u->category ?? $u->trade); ?></small></div>
<?php endforeach; ?>
<?php if (empty($unassigned)) : ?><div class="p-3 text-muted text-center">All assigned</div><?php endif; ?>
</div></div></div>
</div>
@@ -1,10 +0,0 @@
<?php
defined('_JEXEC') or die;
$board=$this->board;$s=$this->stats;
?>
<div class="row g-3 mb-4"><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int)$s->total_today; ?></div><small>Today</small></div></div></div><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-danger"><?php echo (int)$s->urgent; ?></div><small>Urgent</small></div></div></div><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-warning"><?php echo (int)$s->en_route; ?></div><small>En Route</small></div></div></div><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-success"><?php echo (int)$s->completed; ?></div><small>Done</small></div></div></div></div>
<?php foreach($board as $tech): ?>
<div class="card shadow-sm mb-2"><div class="card-body p-2"><strong><?php echo $this->escape($tech->tech_name); ?></strong>
<?php foreach($tech->jobs as $job): ?><div class="ms-3 small"><?php echo $this->escape($job->wo_number); ?> <?php echo $this->escape($job->customer_name); ?></div><?php endforeach; ?>
</div></div>
<?php endforeach; ?>
@@ -1,10 +0,0 @@
<?php
defined('_JEXEC') or die;
$equip=$this->equipment;$due=$this->serviceDue;
?>
<?php if(!empty($due)): ?><div class="alert alert-warning"><?php echo count($due); ?> equipment due</div><?php endif; ?>
<table class="table table-striped"><thead><tr><th>Type</th><th>Make/Model</th><th>Serial</th><th>Owner</th><th>Last Service</th></tr></thead><tbody>
<?php foreach($equip as $e): ?>
<tr><td><?php echo ucfirst(str_replace("_"," ",$e->equipment_type)); ?></td><td><?php echo $this->escape($e->make." ".$e->model); ?></td><td><code><?php echo $this->escape($e->serial_number); ?></code></td><td><?php echo $this->escape($e->owner_name); ?></td><td><?php echo $e->last_service_date?date("M j",strtotime($e->last_service_date)):"Never"; ?></td></tr>
<?php endforeach; ?>
</tbody></table>
@@ -1,8 +0,0 @@
<?php defined('_JEXEC') or die; $agreements=$this->agreements; $rev=$this->revenue; ?>
<div class="row g-3 mb-4"><div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int)$rev->active_agreements; ?></div><small>Active Agreements</small></div></div></div><div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-success">$<?php echo number_format((float)$rev->annual_recurring,0); ?></div><small>Annual Recurring</small></div></div></div><div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold">$<?php echo number_format((float)$rev->monthly_recurring,0); ?></div><small>Monthly</small></div></div></div></div>
<table class="table table-striped"><thead class="table-light"><tr><th>Agreement</th><th>Customer</th><th>Trade</th><th>Visits</th><th>Annual</th><th>Status</th><th>Expires</th></tr></thead><tbody>
<?php foreach($agreements as $a): ?>
<tr><td><strong><?php echo htmlspecialchars($a->title); ?></strong></td><td><?php echo htmlspecialchars($a->customer_name??""); ?></td><td><?php echo ucfirst($a->trade); ?></td><td><?php echo $a->visits_remaining; ?> of <?php echo (int)$a->visits_per_year; ?> left</td><td>$<?php echo number_format((float)$a->annual_amount,0); ?></td><td><span class="badge bg-<?php echo $a->status==="active"?"success":"warning"; ?>"><?php echo ucfirst($a->status); ?></span></td><td><?php echo $a->end_date?date("M j, Y",strtotime($a->end_date)):"&mdash;"; ?></td></tr>
<?php endforeach; ?>
<?php if(empty($agreements)): ?><tr><td colspan="7" class="text-muted text-center py-4">No agreements</td></tr><?php endif; ?>
</tbody></table>
@@ -1,7 +0,0 @@
<?php defined('_JEXEC') or die; $techs=$this->technicians; $statusColors=["available"=>"success","dispatched"=>"info","en_route"=>"warning","on_site"=>"primary","off_duty"=>"secondary","on_leave"=>"dark"]; ?>
<table class="table table-striped table-hover"><thead class="table-light"><tr><th>Tech</th><th>Trade</th><th>Status</th><th>Phone</th><th>Vehicle</th><th>License</th><th>Jobs/Month</th></tr></thead><tbody>
<?php foreach($techs as $t): ?>
<tr><td><strong><?php echo htmlspecialchars($t->tech_name??""); ?></strong></td><td><?php echo ucfirst($t->trade); ?></td><td><span class="badge bg-<?php echo $statusColors[$t->status]??"secondary"; ?>"><?php echo ucfirst(str_replace("_"," ",$t->status)); ?></span></td><td class="small"><?php echo htmlspecialchars($t->telephone??""); ?></td><td><?php echo htmlspecialchars($t->vehicle_number??"&mdash;"); ?></td><td class="small"><?php echo htmlspecialchars($t->license_number??"&mdash;"); ?></td><td><?php echo (int)($t->jobs_this_month??0); ?></td></tr>
<?php endforeach; ?>
<?php if(empty($techs)): ?><tr><td colspan="7" class="text-muted text-center py-4">No technicians</td></tr><?php endif; ?>
</tbody></table>
@@ -1,9 +0,0 @@
<?php
defined('_JEXEC') or die;
$vehicles=$this->vehicles;
?>
<table class="table table-striped"><thead><tr><th>Vehicle</th><th>Make/Model</th><th>Assigned To</th><th>Mileage</th><th>Status</th></tr></thead><tbody>
<?php foreach($vehicles as $v): ?>
<tr><td><strong><?php echo $this->escape($v->vehicle_number); ?></strong></td><td><?php echo $this->escape($v->make." ".$v->model); ?></td><td><?php echo $this->escape($v->assigned_tech_name); ?></td><td><?php echo $v->mileage?number_format((int)$v->mileage):"&mdash;"; ?></td><td><?php echo ucfirst($v->status); ?></td></tr>
<?php endforeach; ?>
</tbody></table>
@@ -1,34 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
$orders = $this->orders;
$f = $this->filters;
$statusColors = ['new'=>'secondary','dispatched'=>'info','en_route'=>'warning','on_site'=>'primary','in_progress'=>'primary','parts_needed'=>'danger','completed'=>'success','invoiced'=>'dark','cancelled'=>'danger'];
$priorityColors = ['emergency'=>'danger','urgent'=>'warning','high'=>'info','normal'=>'primary','low'=>'secondary','scheduled'=>'dark'];
?>
<form action="<?php echo Route::_('index.php?option=com_mokosuitefield&view=workorders'); ?>" method="post" name="adminForm" id="adminForm">
<div class="row g-2 mb-3">
<div class="col-auto"><select name="filter_status" class="form-select form-select-sm" onchange="this.form.submit()"><option value="">All Statuses</option>
<?php foreach($statusColors as $k=>$v): ?><option value="<?php echo $k; ?>" <?php echo $f['status']===$k?'selected':''; ?>><?php echo ucfirst(str_replace('_',' ',$k)); ?></option><?php endforeach; ?>
</select></div>
<div class="col-auto"><input type="date" name="filter_date" class="form-control form-control-sm" value="<?php echo $this->escape($f['date']); ?>" onchange="this.form.submit()" /></div>
<div class="col-auto"><input type="text" name="filter_search" class="form-control form-control-sm" placeholder="Search..." value="<?php echo $this->escape($f['search']); ?>" /></div>
<div class="col-auto"><button type="submit" class="btn btn-sm btn-outline-primary">Filter</button></div>
</div>
<table class="table table-striped table-hover">
<thead class="table-light"><tr><th>WO#</th><th>Customer</th><th>Trade</th><th>Priority</th><th>Status</th><th>Technician</th><th>Scheduled</th><th>Total</th></tr></thead>
<tbody>
<?php foreach($orders as $wo): ?>
<tr><td><code><?php echo $this->escape($wo->wo_number); ?></code></td>
<td><?php echo $this->escape($wo->customer_name??''); ?><br><small class="text-muted"><?php echo $this->escape($wo->city??''); ?></small></td>
<td><?php echo ucfirst($wo->trade); ?></td>
<td><span class="badge bg-<?php echo $priorityColors[$wo->priority]??'secondary'; ?>"><?php echo ucfirst($wo->priority); ?></span></td>
<td><span class="badge bg-<?php echo $statusColors[$wo->status]??'secondary'; ?>"><?php echo ucfirst(str_replace('_',' ',$wo->status)); ?></span></td>
<td><?php echo $this->escape($wo->tech_name??'Unassigned'); ?></td>
<td><?php echo $wo->scheduled_date?date('M j',strtotime($wo->scheduled_date)):'&mdash;'; ?></td>
<td><?php echo (float)$wo->total>0?'$'.number_format((float)$wo->total,2):'&mdash;'; ?></td></tr>
<?php endforeach; ?>
<?php if(empty($orders)): ?><tr><td colspan="8" class="text-muted text-center py-4">No work orders</td></tr><?php endif; ?>
</tbody></table>
<input type="hidden" name="task" value="" /><?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
</form>
@@ -1,178 +0,0 @@
<?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;
/**
* Equipment + Vehicles + Service Agreements API.
*
* GET /equipment — List equipment
* GET /equipment/{id} — Equipment detail with service history
* GET /vehicles — List vehicles (fleet)
* GET /vehicles/{id}/stock — Truck stock for a vehicle
* GET /agreements — List service agreements
* GET /agreements/{id} — Agreement detail with WO history
*/
class FieldEquipmentController extends BaseController
{
private function requireAuth(string $action = 'core.manage'): void
{
$user = Factory::getApplication()->getIdentity();
if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) {
http_response_code(403);
echo json_encode(['error' => 'Access denied.']);
Factory::getApplication()->close();
}
}
public function listEquipment(): void
{
$this->requireAuth('core.manage');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$input = Factory::getApplication()->getInput();
$query = $db->getQuery(true)
->select('eq.*, loc.name AS location_name, loc.address')
->select('cd.name AS customer_name')
->from($db->quoteName('#__mokosuitefield_equipment', 'eq'))
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = eq.location_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = eq.contact_id')
->order('eq.name ASC');
$type = $input->getString('type', '');
if ($type) $query->where($db->quoteName('eq.type') . ' = ' . $db->quote($type));
$status = $input->getString('status', '');
if ($status) $query->where($db->quoteName('eq.status') . ' = ' . $db->quote($status));
$locationId = $input->getInt('location_id', 0);
if ($locationId) $query->where('eq.location_id = ' . $locationId);
$db->setQuery($query, 0, 100);
$this->sendJson($db->loadObjectList() ?: []);
}
public function getEquipment(): void
{
$this->requireAuth('core.manage');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db->setQuery($db->getQuery(true)
->select('eq.*, loc.name AS location_name, loc.address, cd.name AS customer_name')
->from($db->quoteName('#__mokosuitefield_equipment', 'eq'))
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = eq.location_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = eq.contact_id')
->where('eq.id = ' . $id));
$equipment = $db->loadObject();
if (!$equipment) {
http_response_code(404);
$this->sendJson(['error' => 'Equipment not found']);
return;
}
// Service history
$db->setQuery($db->getQuery(true)
->select('wo.id, wo.wo_number, wo.description, wo.status, wo.completed_at, wo.trade')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->where('wo.equipment_id = ' . $id)
->order('wo.scheduled_date DESC'), 0, 20);
$equipment->service_history = $db->loadObjectList() ?: [];
$this->sendJson($equipment);
}
public function listVehicles(): void
{
$this->requireAuth('core.manage');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('v.*, t_cd.name AS tech_name')
->from($db->quoteName('#__mokosuitefield_vehicles', 'v'))
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.technician_id')
->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id')
->order('v.vehicle_name ASC'));
$this->sendJson($db->loadObjectList() ?: []);
}
public function truckStock(): void
{
$this->requireAuth('core.manage');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$vehicleId = Factory::getApplication()->getInput()->getInt('id', 0);
$db->setQuery($db->getQuery(true)
->select('ts.*, p.title AS product_name')
->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts'))
->join('LEFT', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id')
->where('ts.vehicle_id = ' . $vehicleId)
->order('p.title ASC'));
$this->sendJson($db->loadObjectList() ?: []);
}
public function listAgreements(): void
{
$this->requireAuth('core.manage');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$input = Factory::getApplication()->getInput();
$query = $db->getQuery(true)
->select('sa.*, cd.name AS customer_name, loc.address')
->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = sa.location_id')
->order('sa.end_date ASC');
$status = $input->getString('status', '');
if ($status) $query->where($db->quoteName('sa.status') . ' = ' . $db->quote($status));
$db->setQuery($query, 0, 100);
$this->sendJson($db->loadObjectList() ?: []);
}
public function getAgreement(): void
{
$this->requireAuth('core.manage');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db->setQuery($db->getQuery(true)
->select('sa.*, cd.name AS customer_name, loc.name AS location_name, loc.address')
->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = sa.location_id')
->where('sa.id = ' . $id));
$agreement = $db->loadObject();
if (!$agreement) {
http_response_code(404);
$this->sendJson(['error' => 'Agreement not found']);
return;
}
// Work orders under this agreement
$db->setQuery($db->getQuery(true)
->select('wo.id, wo.wo_number, wo.description, wo.status, wo.scheduled_date, wo.trade')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->where('wo.agreement_id = ' . $id)
->order('wo.scheduled_date DESC'), 0, 30);
$agreement->work_orders = $db->loadObjectList() ?: [];
$this->sendJson($agreement);
}
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();
}
}
@@ -1,150 +0,0 @@
<?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;
/**
* Estimates + Route API.
*
* GET /estimates — List estimates
* POST /estimates — Create estimate from field
* PATCH /estimates/{id}/status — Update estimate status (approve/reject)
* POST /estimates/{id}/convert— Convert estimate to work order
* GET /route/{techId} — Get optimized daily route
* POST /route/{techId}/optimize — Trigger route optimization
*/
class FieldEstimatesController extends BaseController
{
private function requireAuth(string $action = 'core.manage'): void
{
$user = Factory::getApplication()->getIdentity();
if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) {
http_response_code(403);
echo json_encode(['error' => 'Access denied.']);
Factory::getApplication()->close();
}
}
public function listEstimates(): void
{
$this->requireAuth('core.manage');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$input = Factory::getApplication()->getInput();
$query = $db->getQuery(true)
->select('e.*, cd.name AS customer_name, loc.address')
->from($db->quoteName('#__mokosuitefield_estimates', 'e'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id')
->order('e.created DESC');
$status = $input->getString('status', '');
if ($status) $query->where($db->quoteName('e.status') . ' = ' . $db->quote($status));
$techId = $input->getInt('technician_id', 0);
if ($techId) $query->where('e.technician_id = ' . $techId);
$db->setQuery($query, 0, 100);
$this->sendJson($db->loadObjectList() ?: []);
}
public function createEstimate(): void
{
$this->requireAuth('core.create');
$input = Factory::getApplication()->getInput();
$estimateId = \Moko\Plugin\System\MokoSuiteField\Helper\EstimateHelper::createEstimate(
$input->getInt('contact_id', 0),
$input->getInt('location_id', 0),
$input->getString('trade', 'general'),
$input->getString('description', ''),
json_decode($input->getString('line_items', '[]'), true) ?: []
);
$this->sendJson(['success' => true, 'estimate_id' => $estimateId]);
}
public function updateStatus(): void
{
$this->requireAuth('core.edit');
$input = Factory::getApplication()->getInput();
$id = $input->getInt('id', 0);
$status = $input->getString('status', '');
if (!in_array($status, ['sent', 'approved', 'rejected', 'expired'])) {
http_response_code(400);
$this->sendJson(['error' => 'Invalid status']);
return;
}
$db = Factory::getContainer()->get(DatabaseInterface::class);
$update = (object) [
'id' => $id,
'status' => $status,
];
if ($status === 'approved') {
$update->approved_at = Factory::getDate()->toSql();
}
$db->updateObject('#__mokosuitefield_estimates', $update, 'id');
$this->sendJson(['success' => true]);
}
public function convertToWorkOrder(): void
{
$this->requireAuth('core.create');
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$woId = \Moko\Plugin\System\MokoSuiteField\Helper\EstimateHelper::convertToWorkOrder($id);
if (!$woId) {
http_response_code(400);
$this->sendJson(['error' => 'Could not convert estimate']);
return;
}
$this->sendJson(['success' => true, 'work_order_id' => $woId]);
}
public function getRoute(): void
{
$this->requireAuth('core.manage');
$techId = Factory::getApplication()->getInput()->getInt('tech_id', 0);
$date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d'));
$route = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::getTechRoute($techId, $date);
$metrics = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::estimateRouteMetrics($techId, $date);
$this->sendJson([
'route' => $route,
'metrics' => $metrics,
]);
}
public function optimizeRoute(): void
{
$this->requireAuth('core.manage');
$techId = Factory::getApplication()->getInput()->getInt('tech_id', 0);
$date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d'));
$optimized = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::optimizeRoute($techId, $date);
$metrics = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::estimateRouteMetrics($techId, $date);
$this->sendJson([
'route' => $optimized,
'metrics' => $metrics,
]);
}
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();
}
}
@@ -1,252 +0,0 @@
<?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;
/**
* Mobile tech API — GPS tracking, photo upload, time tracking, parts usage.
* Designed for offline-capable mobile apps (queue + sync).
*
* GET /mobile/jobs — Today's jobs for authenticated tech
* POST /mobile/status — Update WO status with GPS
* POST /mobile/photo — Upload work order photo
* POST /mobile/time/start — Start time entry
* POST /mobile/time/stop — Stop time entry
* POST /mobile/part — Log part usage from truck stock
* POST /mobile/location — GPS heartbeat
* GET /mobile/equipment/{qr} — QR code equipment lookup
*/
class FieldMobileController extends BaseController
{
private function requireTech(): object
{
$user = Factory::getApplication()->getIdentity();
if (!$user || $user->guest) {
http_response_code(401);
echo json_encode(['error' => 'Authentication required.']);
Factory::getApplication()->close();
}
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('t.*, cd.name AS tech_name')
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
->join('INNER', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->where('cd.user_id = ' . (int) $user->id));
$tech = $db->loadObject();
if (!$tech) {
http_response_code(403);
echo json_encode(['error' => 'No technician profile.']);
Factory::getApplication()->close();
}
return $tech;
}
public function myJobs(): void
{
$tech = $this->requireTech();
$db = Factory::getContainer()->get(DatabaseInterface::class);
$today = date('Y-m-d');
$db->setQuery($db->getQuery(true)
->select('wo.*, cd.name AS customer_name, cd.telephone AS customer_phone')
->select('loc.address, loc.city, loc.state, loc.zip, loc.latitude, loc.longitude, loc.access_notes')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
->where('wo.technician_id = ' . (int) $tech->id)
->where('(wo.scheduled_date = ' . $db->quote($today) . ' OR wo.status IN (' . $db->quote('dispatched') . ',' . $db->quote('en_route') . ',' . $db->quote('on_site') . ',' . $db->quote('in_progress') . '))')
->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ') ASC'));
$this->sendJson($db->loadObjectList() ?: []);
}
public function updateStatus(): void
{
$tech = $this->requireTech();
$input = Factory::getApplication()->getInput();
\Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::updateStatus(
$input->getInt('work_order_id', 0),
$input->getString('status', ''),
$input->getFloat('lat', 0) ?: null,
$input->getFloat('lng', 0) ?: null
);
$this->sendJson(['message' => 'Status updated.']);
}
public function uploadPhoto(): void
{
$tech = $this->requireTech();
$input = Factory::getApplication()->getInput();
$db = Factory::getContainer()->get(DatabaseInterface::class);
$woId = $input->getInt('work_order_id', 0);
$photoType = $input->getString('photo_type', 'other');
$caption = $input->getString('caption', '');
$lat = $input->getFloat('lat', 0) ?: null;
$lng = $input->getFloat('lng', 0) ?: null;
// Handle file upload
$file = $input->files->get('photo');
if (!$file || $file['error'] !== 0) {
http_response_code(400);
$this->sendJson(['error' => 'No photo uploaded.']);
return;
}
$uploadDir = 'media/com_mokosuitefield/photos/' . date('Y/m/');
if (!is_dir(JPATH_ROOT . '/' . $uploadDir)) {
mkdir(JPATH_ROOT . '/' . $uploadDir, 0755, true);
}
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = 'wo' . $woId . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$filePath = $uploadDir . $filename;
move_uploaded_file($file['tmp_name'], JPATH_ROOT . '/' . $filePath);
$db->insertObject('#__mokosuitefield_wo_photos', (object) [
'work_order_id' => $woId,
'file_path' => $filePath,
'photo_type' => $photoType,
'caption' => $caption,
'latitude' => $lat,
'longitude' => $lng,
'taken_at' => Factory::getDate()->toSql(),
'uploaded_by' => Factory::getApplication()->getIdentity()->id,
'created' => Factory::getDate()->toSql(),
], 'id');
$this->sendJson(['message' => 'Photo uploaded.', 'path' => $filePath]);
}
public function startTime(): void
{
$tech = $this->requireTech();
$input = Factory::getApplication()->getInput();
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->insertObject('#__mokosuitefield_time_entries', (object) [
'work_order_id' => $input->getInt('work_order_id', 0),
'technician_id' => $tech->id,
'start_time' => Factory::getDate()->toSql(),
'is_travel' => $input->getInt('is_travel', 0),
'rate' => $tech->hourly_rate,
'created' => Factory::getDate()->toSql(),
], 'id');
$this->sendJson(['message' => 'Timer started.']);
}
public function stopTime(): void
{
$tech = $this->requireTech();
$input = Factory::getApplication()->getInput();
$db = Factory::getContainer()->get(DatabaseInterface::class);
$entryId = $input->getInt('entry_id', 0);
$now = Factory::getDate()->toSql();
$db->setQuery($db->getQuery(true)->select('start_time, rate')->from('#__mokosuitefield_time_entries')->where('id = ' . $entryId));
$entry = $db->loadObject();
if (!$entry) {
http_response_code(404);
$this->sendJson(['error' => 'Time entry not found.']);
return;
}
$hours = round((strtotime($now) - strtotime($entry->start_time)) / 3600, 2);
$db->updateObject('#__mokosuitefield_time_entries', (object) [
'id' => $entryId,
'end_time' => $now,
'hours' => $hours,
], 'id');
$this->sendJson(['message' => 'Timer stopped.', 'hours' => $hours]);
}
public function logPart(): void
{
$tech = $this->requireTech();
$input = Factory::getApplication()->getInput();
$productId = $input->getInt('product_id', 0);
$qty = $input->getFloat('quantity', 1);
$woId = $input->getInt('work_order_id', 0);
// Deduct from truck stock
\Moko\Plugin\System\MokoSuiteField\Helper\TruckStockHelper::usePart(
(int) $tech->vehicle_id, $productId, $qty
);
// Add as WO line item
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)->select('name, cost_price, price')->from('#__mokosuite_crm_products')->where('id = ' . $productId));
$product = $db->loadObject();
if ($product) {
$db->insertObject('#__mokosuitefield_wo_items', (object) [
'work_order_id' => $woId,
'item_type' => 'part',
'product_id' => $productId,
'description' => $product->name,
'quantity' => $qty,
'unit_price' => (float) $product->price,
'line_total' => $qty * (float) $product->price,
'from_truck_stock' => 1,
]);
}
$this->sendJson(['message' => 'Part logged.']);
}
public function gpsHeartbeat(): void
{
$tech = $this->requireTech();
$input = Factory::getApplication()->getInput();
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->updateObject('#__mokosuitefield_technicians', (object) [
'id' => $tech->id,
'current_lat' => $input->getFloat('lat', 0),
'current_lng' => $input->getFloat('lng', 0),
'last_location_update' => Factory::getDate()->toSql(),
], 'id');
$this->sendJson(['message' => 'Location updated.']);
}
public function equipmentLookup(): void
{
$this->requireTech();
$qr = Factory::getApplication()->getInput()->getString('qr', '');
$equipment = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getByQrCode($qr);
if (!$equipment) {
http_response_code(404);
$this->sendJson(['error' => 'Equipment not found.']);
return;
}
$this->sendJson($equipment);
}
private function sendJson(mixed $data): void
{
$app = Factory::getApplication();
$app->getDocument()->setMimeEncoding('application/json');
echo json_encode(['data' => $data], JSON_THROW_ON_ERROR);
$app->close();
}
}
@@ -1,122 +0,0 @@
<?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();
}
}
@@ -1,83 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
/**
* Scheduling + Daily Route API.
*
* GET /schedule/slots — Available appointment slots
* POST /schedule/book — Schedule a work order
* GET /schedule/today — Today's schedule for all techs
* GET /schedule/tech/{id} — Single tech's daily route
*/
class FieldSchedulingController extends BaseController
{
private function requireAuth(string $action = 'core.manage'): void
{
$user = Factory::getApplication()->getIdentity();
if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) {
http_response_code(403);
echo json_encode(['error' => 'Access denied']);
Factory::getApplication()->close();
}
}
public function availableSlots(): void
{
$this->requireAuth();
$input = Factory::getApplication()->getInput();
$slots = \Moko\Plugin\System\MokoSuiteField\Helper\SchedulingHelper::getAvailableSlots(
$input->getString('date', date('Y-m-d')),
$input->getString('trade', 'general'),
$input->getInt('duration', 60)
);
$this->sendJson($slots);
}
public function bookSlot(): void
{
$this->requireAuth('core.create');
$input = Factory::getApplication()->getInput();
$result = \Moko\Plugin\System\MokoSuiteField\Helper\SchedulingHelper::scheduleWorkOrder(
$input->getInt('wo_id', 0),
$input->getString('date', ''),
$input->getString('time', ''),
$input->getInt('tech_id', 0) ?: null
);
$this->sendJson(['success' => $result]);
}
public function todaySchedule(): void
{
$this->requireAuth();
$schedule = \Moko\Plugin\System\MokoSuiteField\Helper\SchedulingHelper::getTodaySchedule();
$this->sendJson($schedule);
}
public function techRoute(): void
{
$this->requireAuth();
$techId = Factory::getApplication()->getInput()->getInt('tech_id', 0);
$date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d'));
$route = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::getTechRoute($techId, $date);
$metrics = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::estimateRouteMetrics($techId, $date);
$this->sendJson(['route' => $route, 'metrics' => $metrics]);
}
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();
}
}
@@ -1,131 +0,0 @@
<?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;
/**
* Work Order + Dispatch API.
*
* GET /workorders — List work orders
* POST /workorders — Create work order
* PATCH /workorders/{id}/status — Update status (with GPS)
* POST /workorders/{id}/dispatch — Dispatch to technician
* GET /dispatch/board — Today's dispatch board
* GET /technicians/available — Available techs by trade
*/
class FieldWorkOrderController extends BaseController
{
private function requireAuth(string $action = 'core.manage'): void
{
$user = Factory::getApplication()->getIdentity();
if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) {
http_response_code(403);
echo json_encode(['error' => 'Access denied.']);
Factory::getApplication()->close();
}
}
public function list(): void
{
$this->requireAuth('core.manage');
$db = Factory::getContainer()->get(DatabaseInterface::class);
$input = Factory::getApplication()->getInput();
$status = $input->getString('status', '');
$techId = $input->getInt('technician_id', 0);
$date = $input->getString('date', '');
$query = $db->getQuery(true)
->select('wo.*, cd.name AS customer_name, loc.address, loc.city, t_cd.name AS tech_name')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id')
->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id')
->order('wo.scheduled_date ASC, wo.scheduled_time_start ASC');
if ($status) $query->where($db->quoteName('wo.status') . ' = ' . $db->quote($status));
if ($techId) $query->where('wo.technician_id = ' . $techId);
if ($date) $query->where('wo.scheduled_date = ' . $db->quote($date));
$db->setQuery($query, 0, 100);
$this->sendJson($db->loadObjectList() ?: []);
}
public function create(): void
{
$this->requireAuth('core.create');
$input = Factory::getApplication()->getInput();
$woId = \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::create(
$input->getInt('contact_id', 0),
$input->getString('trade', 'general'),
$input->getString('description', ''),
[
'location_id' => $input->getInt('location_id', 0),
'priority' => $input->getString('priority', 'normal'),
'category' => $input->getString('category', ''),
'scheduled_date' => $input->getString('scheduled_date', ''),
'time_start' => $input->getString('time_start', ''),
'source' => $input->getString('source', 'phone'),
]
);
$this->sendJson(['id' => $woId, 'message' => 'Work order created.']);
}
public function updateStatus(): void
{
$input = Factory::getApplication()->getInput();
\Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::updateStatus(
$input->getInt('id', 0),
$input->getString('status', ''),
$input->getFloat('lat', 0) ?: null,
$input->getFloat('lng', 0) ?: null
);
$this->sendJson(['message' => 'Status updated.']);
}
public function dispatchToTech(): void
{
$this->requireAuth('field.dispatch');
$input = Factory::getApplication()->getInput();
\Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::dispatch(
$input->getInt('work_order_id', 0),
$input->getInt('technician_id', 0)
);
$this->sendJson(['message' => 'Dispatched.']);
}
public function board(): void
{
$date = Factory::getApplication()->getInput()->getString('date', '');
$this->sendJson(\Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getDispatchBoard($date));
}
public function availableTechs(): void
{
$input = Factory::getApplication()->getInput();
$tech = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::findBestTech(
$input->getString('trade', 'general'),
$input->getString('zip', '')
);
$this->sendJson($tech ? [$tech] : []);
}
private function sendJson(mixed $data): void
{
$app = Factory::getApplication();
$app->getDocument()->setMimeEncoding('application/json');
echo json_encode(['data' => $data], JSON_THROW_ON_ERROR);
$app->close();
}
}
@@ -1,9 +0,0 @@
/* MokoSuite Field Service Styles */
.dispatch-board .tech-card { border-left: 4px solid #198754; }
.dispatch-board .tech-card.dispatched { border-left-color: #0d6efd; }
.dispatch-board .tech-card.on-site { border-left-color: #ffc107; }
.wo-priority-emergency { background-color: rgba(220, 53, 69, 0.1) !important; }
.wo-priority-urgent { background-color: rgba(255, 193, 7, 0.08) !important; }
.tech-mobile .current-job { border: 2px solid #0d6efd; }
@media (max-width: 768px) { .tech-mobile .card { margin-bottom: 0.5rem; } }
@media print { .btn, .toolbar { display: none !important; } }
@@ -1,6 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
// Auto-refresh dispatch board every 30 seconds
if (document.querySelector('.dispatch-board')) {
setInterval(function() { location.reload(); }, 30000);
}
});
@@ -1,8 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
class DisplayController extends BaseController
{
protected $default_view = 'bookservice';
}
@@ -1,21 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Site\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Component\Router\RouterBase;
class Router extends RouterBase
{
public function build(&$query): array
{
$segments = [];
if (isset($query['view'])) { $segments[] = $query['view']; unset($query['view']); }
if (isset($query['id'])) { $segments[] = $query['id']; unset($query['id']); }
return $segments;
}
public function parse(&$segments): array
{
$vars = [];
if (!empty($segments[0])) $vars['view'] = array_shift($segments);
if (!empty($segments[0]) && is_numeric($segments[0])) $vars['id'] = (int) array_shift($segments);
return $vars;
}
}
@@ -1,78 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Site\View\BookService;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\Database\DatabaseInterface;
/**
* Public service booking page — customers request service online.
*/
class HtmlView extends BaseHtmlView
{
public bool $submitted = false;
public string $companyName = '';
public array $trades = [];
public function display($tpl = null): void
{
$app = Factory::getApplication();
$params = $app->getParams('com_mokosuitefield');
$this->companyName = $params->get('company_name', $app->get('sitename'));
$this->trades = [
'plumbing' => 'Plumbing',
'electrical' => 'Electrical',
'hvac' => 'HVAC / Heating & Cooling',
'appliance' => 'Appliance Repair',
'general' => 'General Maintenance',
'carpentry' => 'Carpentry',
'painting' => 'Painting',
'roofing' => 'Roofing',
'landscaping' => 'Landscaping',
'locksmith' => 'Locksmith',
];
if ($app->getInput()->getMethod() === 'POST' && \Joomla\CMS\Session\Session::checkToken()) {
$input = $app->getInput();
$db = Factory::getContainer()->get(DatabaseInterface::class);
$name = $input->getString('name', '');
$email = $input->getString('email', '');
$phone = $input->getString('phone', '');
$address = $input->getString('address', '');
$trade = $input->getString('trade', 'general');
$desc = $input->getString('description', '');
$priority = $input->getString('priority', 'normal');
if ($name && $phone && $desc) {
// Find or create contact
$db->setQuery($db->getQuery(true)->select('id')->from('#__contact_details')
->where($db->quoteName('telephone') . ' = ' . $db->quote($phone)));
$contactId = (int) $db->loadResult();
if (!$contactId) {
$db->insertObject('#__contact_details', (object) [
'name' => $name, 'email_to' => $email, 'telephone' => $phone,
'address' => $address, 'published' => 1, 'created' => Factory::getDate()->toSql(),
], 'id');
$contactId = $db->insertid();
}
// Create work order
\Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::create(
(int) $contactId, $trade, $desc, [
'priority' => $priority,
'source' => 'website',
]
);
$this->submitted = true;
}
}
parent::display($tpl);
}
}
@@ -1,89 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Site\View\CustomerPortal;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\Database\DatabaseInterface;
/**
* Customer portal — view work order status, service history, equipment, agreements.
* "When is the tech coming?" and "What was done?" — self-service answers.
*/
class HtmlView extends BaseHtmlView
{
public ?int $contactId = null;
public array $activeOrders = [];
public array $orderHistory = [];
public array $equipment = [];
public array $agreements = [];
public ?object $nextService = null;
public function display($tpl = null): void
{
$app = Factory::getApplication();
$user = $app->getIdentity();
if (!$user || $user->guest) {
$app->redirect('index.php?option=com_users&view=login');
return;
}
$db = Factory::getContainer()->get(DatabaseInterface::class);
// Resolve contact from Joomla user
$db->setQuery($db->getQuery(true)->select('id')->from('#__contact_details')->where('user_id = ' . (int) $user->id));
$this->contactId = (int) $db->loadResult();
if (!$this->contactId) {
$app->enqueueMessage('No customer profile found.', 'warning');
return;
}
// Active work orders (in-progress)
$db->setQuery($db->getQuery(true)
->select('wo.*, t_cd.name AS tech_name, t.trade AS tech_trade, loc.address')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id')
->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
->where('wo.contact_id = ' . $this->contactId)
->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('completed') . ',' . $db->quote('invoiced') . ',' . $db->quote('cancelled') . ')')
->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ') ASC'));
$this->activeOrders = $db->loadObjectList() ?: [];
// Recent completed orders (last 10)
$db->setQuery($db->getQuery(true)
->select('wo.wo_number, wo.trade, wo.category, wo.total, wo.actual_departure AS completed_at, wo.work_performed')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->where('wo.contact_id = ' . $this->contactId)
->where($db->quoteName('wo.status') . ' IN (' . $db->quote('completed') . ',' . $db->quote('invoiced') . ')')
->order('wo.actual_departure DESC'), 0, 10);
$this->orderHistory = $db->loadObjectList() ?: [];
// Equipment at customer locations
$db->setQuery($db->getQuery(true)
->select('e.*, loc.address')
->from($db->quoteName('#__mokosuitefield_equipment', 'e'))
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id')
->where('e.contact_id = ' . $this->contactId)
->order('e.equipment_type ASC'));
$this->equipment = $db->loadObjectList() ?: [];
// Active service agreements
$this->agreements = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getActiveAgreements($this->contactId);
// Next scheduled service
$db->setQuery($db->getQuery(true)
->select('wo.wo_number, wo.scheduled_date, wo.scheduled_time_start, wo.trade, wo.category')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->where('wo.contact_id = ' . $this->contactId)
->where($db->quoteName('wo.scheduled_date') . ' >= CURDATE()')
->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ')')
->order('wo.scheduled_date ASC, wo.scheduled_time_start ASC'), 0, 1);
$this->nextService = $db->loadObject();
parent::display($tpl);
}
}
@@ -1,90 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Site\View\EstimateView;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\Database\DatabaseInterface;
/**
* Public estimate approval page — customer views and approves/rejects a field service estimate.
*/
class HtmlView extends BaseHtmlView
{
public ?object $estimate = null;
public array $lineItems = [];
public bool $actioned = false;
public string $actionResult = '';
public function display($tpl = null): void
{
$app = Factory::getApplication();
$input = $app->getInput();
$db = Factory::getContainer()->get(DatabaseInterface::class);
$token = $input->getString('token', '');
if (!$token) {
$app->enqueueMessage('Invalid estimate link.', 'warning');
parent::display($tpl);
return;
}
// Load estimate by token
$db->setQuery($db->getQuery(true)
->select('e.*, cd.name AS customer_name, cd.email_to, cd.telephone')
->select('l.address, l.city, l.state, l.zip')
->from($db->quoteName('#__mokosuitefield_estimates', 'e'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'l') . ' ON l.id = e.location_id')
->where($db->quoteName('e.approval_token') . ' = ' . $db->quote($token)));
$this->estimate = $db->loadObject();
if (!$this->estimate) {
$app->enqueueMessage('Estimate not found or link expired.', 'warning');
parent::display($tpl);
return;
}
// Load line items
$db->setQuery($db->getQuery(true)
->select('*')
->from('#__mokosuitefield_estimate_items')
->where('estimate_id = ' . (int) $this->estimate->id)
->order('ordering ASC'));
$this->lineItems = $db->loadObjectList() ?: [];
// Handle approval/rejection (CSRF check not required — token-based public page)
if ($input->getMethod() === 'POST' && \Joomla\CMS\Session\Session::checkToken()) {
$action = $input->getString('action', '');
if ($action === 'approve' && $this->estimate->status === 'sent') {
$db->setQuery($db->getQuery(true)
->update('#__mokosuitefield_estimates')
->set($db->quoteName('status') . ' = ' . $db->quote('approved'))
->set($db->quoteName('approved_at') . ' = ' . $db->quote(Factory::getDate()->toSql()))
->set($db->quoteName('customer_signature') . ' = ' . $db->quote($input->getString('signature', '')))
->where('id = ' . (int) $this->estimate->id));
$db->execute();
$this->actioned = true;
$this->actionResult = 'approved';
$this->estimate->status = 'approved';
} elseif ($action === 'reject' && $this->estimate->status === 'sent') {
$db->setQuery($db->getQuery(true)
->update('#__mokosuitefield_estimates')
->set($db->quoteName('status') . ' = ' . $db->quote('rejected'))
->set($db->quoteName('rejection_reason') . ' = ' . $db->quote($input->getString('reason', '')))
->where('id = ' . (int) $this->estimate->id));
$db->execute();
$this->actioned = true;
$this->actionResult = 'rejected';
$this->estimate->status = 'rejected';
}
}
parent::display($tpl);
}
}
@@ -1,70 +0,0 @@
<?php
namespace Moko\Component\MokoSuiteField\Site\View\TechMobile;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\Database\DatabaseInterface;
/**
* Technician mobile view — tablet/phone for field techs.
* Shows today's jobs, GPS navigation, status updates, time tracking, photos.
*/
class HtmlView extends BaseHtmlView
{
public ?object $tech = null;
public array $todayJobs = [];
public ?object $currentJob = null;
public function display($tpl = null): void
{
$app = Factory::getApplication();
$user = $app->getIdentity();
if (!$user || $user->guest) {
$app->redirect('index.php?option=com_users&view=login');
return;
}
$db = Factory::getContainer()->get(DatabaseInterface::class);
// Find technician record for logged-in user
$db->setQuery($db->getQuery(true)
->select('t.*, cd.name AS tech_name')
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
->join('INNER', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->join('INNER', $db->quoteName('#__users', 'u') . ' ON u.id = ' . (int) $user->id)
->where('cd.user_id = ' . (int) $user->id));
$this->tech = $db->loadObject();
if (!$this->tech) {
$app->enqueueMessage('No technician profile found for your account.', 'warning');
return;
}
$today = date('Y-m-d');
// Today's assigned jobs
$db->setQuery($db->getQuery(true)
->select('wo.*, cd.name AS customer_name, cd.telephone AS customer_phone')
->select('loc.address, loc.city, loc.state, loc.zip, loc.latitude, loc.longitude, loc.access_notes')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
->where('wo.technician_id = ' . (int) $this->tech->id)
->where('(wo.scheduled_date = ' . $db->quote($today) . ' OR (wo.scheduled_date IS NULL AND wo.status NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ',' . $db->quote('invoiced') . ')))')
->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ',' . $db->quote('low') . ') ASC, wo.scheduled_time_start ASC'));
$this->todayJobs = $db->loadObjectList() ?: [];
// Current active job (en_route, on_site, or in_progress)
foreach ($this->todayJobs as $job) {
if (in_array($job->status, ['en_route', 'on_site', 'in_progress'])) {
$this->currentJob = $job;
break;
}
}
parent::display($tpl);
}
}
@@ -1,33 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Uri\Uri;
$company = $this->companyName;
$trades = $this->trades;
if ($this->submitted) : ?>
<div class="container py-5 text-center"><span class="icon-check-circle text-success" style="font-size:4rem;"></span><h2>Service Request Received</h2><p class="text-muted">We will contact you shortly to schedule your appointment.</p></div>
<?php return; endif; ?>
<div class="container py-4" style="max-width:600px;">
<h2 class="text-center mb-2">Request Service</h2>
<p class="text-center text-muted mb-4"><?php echo $this->escape($company); ?></p>
<div class="card shadow-sm"><div class="card-body">
<form method="post" action="<?php echo Uri::current(); ?>">
<div class="row g-3 mb-3">
<div class="col-md-6"><label class="form-label">Name *</label><input type="text" name="name" class="form-control" required /></div>
<div class="col-md-6"><label class="form-label">Phone *</label><input type="tel" name="phone" class="form-control" required /></div>
</div>
<div class="mb-3"><label class="form-label">Email</label><input type="email" name="email" class="form-control" /></div>
<div class="mb-3"><label class="form-label">Service Address</label><textarea name="address" class="form-control" rows="2"></textarea></div>
<div class="row g-3 mb-3">
<div class="col-md-6"><label class="form-label">Service Type *</label><select name="trade" class="form-select">
<?php foreach ($trades as $k=>$v) : ?><option value="<?php echo $k; ?>"><?php echo $v; ?></option><?php endforeach; ?>
</select></div>
<div class="col-md-6"><label class="form-label">Priority</label><select name="priority" class="form-select">
<option value="normal">Normal</option><option value="urgent">Urgent</option><option value="emergency">Emergency</option>
</select></div>
</div>
<div class="mb-3"><label class="form-label">Describe the problem *</label><textarea name="description" class="form-control" rows="4" required></textarea></div>
<button type="submit" class="btn btn-primary btn-lg w-100"><span class="icon-wrench"></span> Submit Service Request</button>
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
</form>
</div></div>
</div>
@@ -1,14 +0,0 @@
<?php
defined('_JEXEC') or die;
if (!$this->contactId) return;
$active=$this->activeOrders;$history=$this->orderHistory;$next=$this->nextService;
?>
<div class="container py-3"><h3>My Service Portal</h3>
<?php if ($next): ?><div class="card shadow-sm mb-3 border-primary"><div class="card-body"><h5>Next Service</h5><strong><?php echo ucfirst($next->trade); ?></strong> — <?php echo date("l, F j",strtotime($next->scheduled_date)); ?></div></div><?php endif; ?>
<?php if (!empty($active)): ?><div class="card shadow-sm mb-3"><div class="card-header bg-warning text-dark"><h5 class="mb-0">Active Work Orders</h5></div><div class="card-body p-0"><table class="table mb-0"><thead><tr><th>WO#</th><th>Service</th><th>Status</th></tr></thead><tbody>
<?php foreach($active as $wo): ?><tr><td><code><?php echo $this->escape($wo->wo_number); ?></code></td><td><?php echo ucfirst($wo->trade); ?></td><td><span class="badge bg-primary"><?php echo ucfirst(str_replace("_"," ",$wo->status)); ?></span></td></tr><?php endforeach; ?>
</tbody></table></div></div><?php endif; ?>
<?php if (!empty($history)): ?><div class="card shadow-sm"><div class="card-header"><h5 class="mb-0">Service History</h5></div><div class="card-body p-0"><table class="table mb-0"><thead><tr><th>WO#</th><th>Service</th><th>Total</th><th>Date</th></tr></thead><tbody>
<?php foreach($history as $h): ?><tr><td><code><?php echo $this->escape($h->wo_number); ?></code></td><td><?php echo ucfirst($h->trade); ?></td><td><?php echo (float)$h->total>0?"$".number_format((float)$h->total,2):""; ?></td><td><?php echo $h->completed_at?date("M j",strtotime($h->completed_at)):""; ?></td></tr><?php endforeach; ?>
</tbody></table></div></div><?php endif; ?>
</div>
@@ -1,96 +0,0 @@
<?php defined('_JEXEC') or die;
if (!$this->estimate) return;
$e = $this->estimate;
$subtotal = 0;
foreach ($this->lineItems as $li) { $subtotal += (float) $li->line_total; }
$tax = round($subtotal * 0.07, 2);
$total = $subtotal + $tax;
?>
<div class="field-estimate-view" style="max-width: 800px; margin: 0 auto;">
<?php if ($this->actioned) : ?>
<div class="alert alert-<?php echo $this->actionResult === 'approved' ? 'success' : 'info'; ?>">
<h4>Estimate <?php echo ucfirst($this->actionResult); ?></h4>
<p><?php echo $this->actionResult === 'approved' ? 'Thank you! We will schedule the work shortly.' : 'The estimate has been declined.'; ?></p>
</div>
<?php endif; ?>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between">
<h3 class="mb-0">Service Estimate</h3>
<span class="badge bg-<?php echo match($e->status) { 'approved' => 'success', 'rejected' => 'danger', 'sent' => 'primary', default => 'secondary' }; ?> fs-6"><?php echo ucfirst($e->status); ?></span>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<strong>Customer:</strong> <?php echo htmlspecialchars($e->customer_name ?? ''); ?><br>
<?php if ($e->address) : ?><strong>Location:</strong> <?php echo htmlspecialchars($e->address . ', ' . $e->city . ', ' . $e->state . ' ' . $e->zip); ?><br><?php endif; ?>
</div>
<div class="col-md-6 text-md-end">
<strong>Estimate #:</strong> <?php echo htmlspecialchars($e->estimate_number ?? $e->id); ?><br>
<strong>Date:</strong> <?php echo date('M j, Y', strtotime($e->created)); ?><br>
<?php if ($e->valid_until) : ?><strong>Valid Until:</strong> <?php echo date('M j, Y', strtotime($e->valid_until)); ?><br><?php endif; ?>
</div>
</div>
<?php if ($e->description) : ?>
<p><strong>Scope of Work:</strong> <?php echo nl2br(htmlspecialchars($e->description)); ?></p>
<?php endif; ?>
<table class="table">
<thead><tr><th>Description</th><th class="text-end">Qty</th><th class="text-end">Unit Price</th><th class="text-end">Total</th></tr></thead>
<tbody>
<?php foreach ($this->lineItems as $li) : ?>
<tr>
<td><?php echo htmlspecialchars($li->description); ?></td>
<td class="text-end"><?php echo (int) $li->quantity; ?></td>
<td class="text-end">$<?php echo number_format((float) $li->unit_price, 2); ?></td>
<td class="text-end">$<?php echo number_format((float) $li->line_total, 2); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr><td colspan="3" class="text-end"><strong>Subtotal</strong></td><td class="text-end">$<?php echo number_format($subtotal, 2); ?></td></tr>
<tr><td colspan="3" class="text-end">Tax</td><td class="text-end">$<?php echo number_format($tax, 2); ?></td></tr>
<tr><td colspan="3" class="text-end"><strong class="fs-5">Total</strong></td><td class="text-end"><strong class="fs-5">$<?php echo number_format($total, 2); ?></strong></td></tr>
</tfoot>
</table>
</div>
</div>
<?php if ($e->status === 'sent') : ?>
<div class="row g-3">
<div class="col-md-6">
<form method="post">
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
<input type="hidden" name="action" value="approve">
<div class="card border-success">
<div class="card-body">
<h5 class="text-success">Approve Estimate</h5>
<div class="mb-3">
<label class="form-label">Type your name to sign</label>
<input type="text" name="signature" class="form-control" placeholder="Your full name" required>
</div>
<button type="submit" class="btn btn-success w-100">Approve & Schedule</button>
</div>
</div>
</form>
</div>
<div class="col-md-6">
<form method="post">
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
<input type="hidden" name="action" value="reject">
<div class="card border-danger">
<div class="card-body">
<h5 class="text-danger">Decline Estimate</h5>
<div class="mb-3">
<label class="form-label">Reason (optional)</label>
<textarea name="reason" class="form-control" rows="2"></textarea>
</div>
<button type="submit" class="btn btn-outline-danger w-100">Decline</button>
</div>
</div>
</form>
</div>
</div>
<?php endif; ?>
</div>
@@ -1,56 +0,0 @@
<?php
defined('_JEXEC') or die;
$tech = $this->tech;
$jobs = $this->todayJobs;
$current = $this->currentJob;
if (!$tech) return;
$statusColors = ['new'=>'secondary','dispatched'=>'info','en_route'=>'warning','on_site'=>'primary','in_progress'=>'primary','parts_needed'=>'danger','completed'=>'success'];
?>
<div class="container-fluid py-2" style="max-width:600px;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0"><?php echo $this->escape($tech->tech_name); ?></h4>
<span class="badge bg-<?php echo $tech->status==='available'?'success':'warning'; ?> fs-6"><?php echo ucfirst(str_replace('_',' ',$tech->status)); ?></span>
</div>
<?php if ($current) : ?>
<div class="card shadow-sm mb-3 border-primary border-2">
<div class="card-header bg-primary text-white"><strong>Current Job: <?php echo $this->escape($current->wo_number); ?></strong></div>
<div class="card-body">
<h5><?php echo $this->escape($current->customer_name); ?></h5>
<p class="mb-1"><span class="icon-map-marker"></span> <?php echo $this->escape($current->address); ?>, <?php echo $this->escape($current->city); ?></p>
<?php if ($current->customer_phone) : ?><p class="mb-1"><a href="tel:<?php echo $current->customer_phone; ?>" class="btn btn-sm btn-outline-primary"><span class="icon-phone"></span> <?php echo $current->customer_phone; ?></a></p><?php endif; ?>
<?php if ($current->access_notes) : ?><div class="alert alert-warning small mb-2"><strong>Access:</strong> <?php echo $this->escape($current->access_notes); ?></div><?php endif; ?>
<p class="small text-muted"><?php echo $this->escape($current->description); ?></p>
<div class="d-flex gap-2">
<?php if ($current->status === 'dispatched') : ?><button class="btn btn-warning flex-grow-1" onclick="updateStatus(<?php echo $current->id; ?>,'en_route')"><span class="icon-truck"></span> En Route</button><?php endif; ?>
<?php if ($current->status === 'en_route') : ?><button class="btn btn-primary flex-grow-1" onclick="updateStatus(<?php echo $current->id; ?>,'on_site')"><span class="icon-map-marker"></span> Arrived</button><?php endif; ?>
<?php if ($current->status === 'on_site') : ?><button class="btn btn-info flex-grow-1" onclick="updateStatus(<?php echo $current->id; ?>,'in_progress')"><span class="icon-wrench"></span> Start Work</button><?php endif; ?>
<?php if ($current->status === 'in_progress') : ?><button class="btn btn-success flex-grow-1" onclick="updateStatus(<?php echo $current->id; ?>,'completed')"><span class="icon-check"></span> Complete</button><?php endif; ?>
<?php if ($current->latitude && $current->longitude) : ?>
<a href="https://maps.google.com/maps?daddr=<?php echo $current->latitude; ?>,<?php echo $current->longitude; ?>" target="_blank" class="btn btn-outline-dark"><span class="icon-compass"></span></a>
<?php endif; ?>
</div>
</div>
</div>
<?php endif; ?>
<h5>Today (<?php echo count($jobs); ?> jobs)</h5>
<?php foreach ($jobs as $job) : if ($current && $job->id === $current->id) continue; ?>
<div class="card shadow-sm mb-2">
<div class="card-body p-2">
<div class="d-flex justify-content-between"><strong class="small"><?php echo $this->escape($job->customer_name); ?></strong><span class="badge bg-<?php echo $statusColors[$job->status]??'secondary'; ?>"><?php echo ucfirst(str_replace('_',' ',$job->status)); ?></span></div>
<small class="text-muted"><?php echo $this->escape($job->city??''); ?> | <?php echo $job->scheduled_time_start?date('g:ia',strtotime($job->scheduled_time_start)):'TBD'; ?> | <?php echo ucfirst($job->trade); ?></small>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($jobs)) : ?><p class="text-muted text-center">No jobs today.</p><?php endif; ?>
</div>
<script>
function updateStatus(woId, status) {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(pos) {
fetch('index.php?option=com_mokosuitefield&task=api.updateStatus&id=' + woId + '&status=' + status + '&lat=' + pos.coords.latitude + '&lng=' + pos.coords.longitude, {method:'POST'}).then(function() { location.reload(); });
}, function() { fetch('index.php?option=com_mokosuitefield&task=api.updateStatus&id=' + woId + '&status=' + status, {method:'POST'}).then(function() { location.reload(); }); });
} else { fetch('index.php?option=com_mokosuitefield&task=api.updateStatus&id=' + woId + '&status=' + status, {method:'POST'}).then(function() { location.reload(); }); }
}
</script>
@@ -1,2 +0,0 @@
PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field"
PLG_SYSTEM_MOKOSUITEFIELD_DESC="MokoSuite Field Service system plugin - database schema and helpers."
@@ -1,2 +0,0 @@
PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field"
PLG_SYSTEM_MOKOSUITEFIELD_DESC="MokoSuite Field Service system plugin."
@@ -1,332 +0,0 @@
--
-- MokoSuite Field Service Tables
--
-- ============================================================
-- Technicians — extends CRM contacts / HRM employees
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_technicians` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`contact_id` INT NOT NULL COMMENT 'FK to #__contact_details',
`employee_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to HRM employees if installed',
`tech_number` VARCHAR(20) NOT NULL DEFAULT '',
`status` ENUM('available','dispatched','en_route','on_site','off_duty','on_leave') NOT NULL DEFAULT 'available',
`trade` ENUM('general','plumbing','electrical','hvac','appliance','carpentry','painting','roofing','landscaping','pest_control','locksmith','multi_trade') NOT NULL DEFAULT 'general',
`license_number` VARCHAR(100) DEFAULT NULL COMMENT 'Trade license (e.g., master plumber)',
`license_expiry` DATE DEFAULT NULL,
`certifications` JSON DEFAULT NULL COMMENT '["EPA 608","backflow certified","journeyman electrician"]',
`skills` JSON DEFAULT NULL,
`hourly_rate` DECIMAL(10,2) DEFAULT NULL,
`overtime_rate` DECIMAL(10,2) DEFAULT NULL,
`max_daily_jobs` INT UNSIGNED NOT NULL DEFAULT 8,
`service_radius_miles` INT UNSIGNED NOT NULL DEFAULT 30,
`home_zip` VARCHAR(10) DEFAULT NULL,
`home_lat` DECIMAL(10,7) DEFAULT NULL,
`home_lng` DECIMAL(10,7) DEFAULT NULL,
`current_lat` DECIMAL(10,7) DEFAULT NULL,
`current_lng` DECIMAL(10,7) DEFAULT NULL,
`last_location_update` DATETIME DEFAULT NULL,
`vehicle_id` INT UNSIGNED DEFAULT NULL,
`notes` TEXT,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_contact` (`contact_id`),
KEY `idx_status` (`status`),
KEY `idx_trade` (`trade`),
KEY `idx_vehicle` (`vehicle_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Service Locations — customer property/site records
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_locations` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`contact_id` INT NOT NULL COMMENT 'FK to CRM contact (property owner)',
`name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'e.g., "Main Office", "123 Oak St Residence"',
`address` TEXT NOT NULL,
`city` VARCHAR(100) NOT NULL DEFAULT '',
`state` VARCHAR(50) NOT NULL DEFAULT '',
`zip` VARCHAR(20) NOT NULL DEFAULT '',
`latitude` DECIMAL(10,7) DEFAULT NULL,
`longitude` DECIMAL(10,7) DEFAULT NULL,
`property_type` ENUM('residential','commercial','industrial','multi_family','government','other') NOT NULL DEFAULT 'residential',
`access_notes` TEXT COMMENT 'Gate codes, parking, pet warnings, key location',
`equipment_notes` TEXT COMMENT 'Known equipment at this location',
`service_history_count` INT UNSIGNED NOT NULL DEFAULT 0,
`last_service_date` DATE DEFAULT NULL,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_contact` (`contact_id`),
KEY `idx_zip` (`zip`),
KEY `idx_type` (`property_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Work Orders — the core job record
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_work_orders` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`wo_number` VARCHAR(30) NOT NULL DEFAULT '',
`contact_id` INT NOT NULL COMMENT 'Customer',
`location_id` INT UNSIGNED DEFAULT NULL,
`technician_id` INT UNSIGNED DEFAULT NULL,
`trade` ENUM('general','plumbing','electrical','hvac','appliance','carpentry','painting','roofing','landscaping','pest_control','locksmith','multi_trade') NOT NULL DEFAULT 'general',
`priority` ENUM('emergency','urgent','high','normal','low','scheduled') NOT NULL DEFAULT 'normal',
`status` ENUM('new','dispatched','en_route','on_site','in_progress','parts_needed','on_hold','completed','invoiced','cancelled') NOT NULL DEFAULT 'new',
`category` VARCHAR(100) DEFAULT NULL COMMENT 'e.g., "water heater", "panel upgrade", "AC repair"',
`description` TEXT NOT NULL,
`customer_po` VARCHAR(100) DEFAULT NULL COMMENT 'Customer PO number',
`scheduled_date` DATE DEFAULT NULL,
`scheduled_time_start` TIME DEFAULT NULL,
`scheduled_time_end` TIME DEFAULT NULL,
`actual_arrival` DATETIME DEFAULT NULL,
`actual_departure` DATETIME DEFAULT NULL,
`work_performed` TEXT,
`diagnosis` TEXT,
`parts_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`labor_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`tax` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`payment_status` ENUM('unpaid','partial','paid','waived') NOT NULL DEFAULT 'unpaid',
`payment_method` VARCHAR(50) DEFAULT NULL,
`customer_signature` TEXT COMMENT 'Base64 signature data',
`customer_signed_at` DATETIME DEFAULT NULL,
`service_agreement_id` INT UNSIGNED DEFAULT NULL,
`invoice_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM invoices',
`deal_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM deals',
`source` ENUM('phone','website','walk_in','referral','service_agreement','emergency','recurring') NOT NULL DEFAULT 'phone',
`created_by` INT NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_wo_number` (`wo_number`),
KEY `idx_contact` (`contact_id`),
KEY `idx_location` (`location_id`),
KEY `idx_tech` (`technician_id`),
KEY `idx_status` (`status`),
KEY `idx_priority` (`priority`),
KEY `idx_trade` (`trade`),
KEY `idx_scheduled` (`scheduled_date`),
KEY `idx_agreement` (`service_agreement_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Work Order Line Items — parts and labor
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_wo_items` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`work_order_id` INT UNSIGNED NOT NULL,
`item_type` ENUM('labor','part','material','flat_rate','discount','permit','disposal') NOT NULL DEFAULT 'labor',
`product_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM products for parts',
`description` VARCHAR(500) NOT NULL,
`quantity` DECIMAL(10,2) NOT NULL DEFAULT 1.00,
`unit_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`line_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`taxable` TINYINT NOT NULL DEFAULT 1,
`from_truck_stock` TINYINT NOT NULL DEFAULT 0 COMMENT 'Taken from tech truck inventory',
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_wo` (`work_order_id`),
KEY `idx_type` (`item_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Work Order Photos — before/after, damage, equipment
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_wo_photos` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`work_order_id` INT UNSIGNED NOT NULL,
`file_path` VARCHAR(500) NOT NULL,
`thumbnail_path` VARCHAR(500) DEFAULT NULL,
`photo_type` ENUM('before','during','after','damage','equipment','permit','other') NOT NULL DEFAULT 'other',
`caption` VARCHAR(500) DEFAULT NULL,
`latitude` DECIMAL(10,7) DEFAULT NULL,
`longitude` DECIMAL(10,7) DEFAULT NULL,
`taken_at` DATETIME DEFAULT NULL,
`uploaded_by` INT NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_wo` (`work_order_id`),
KEY `idx_type` (`photo_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Service Agreements — recurring maintenance contracts
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_agreements` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`contact_id` INT NOT NULL,
`location_id` INT UNSIGNED DEFAULT NULL,
`agreement_number` VARCHAR(30) NOT NULL DEFAULT '',
`title` VARCHAR(255) NOT NULL,
`trade` ENUM('general','plumbing','electrical','hvac','appliance','multi_trade') NOT NULL DEFAULT 'general',
`agreement_type` ENUM('preventive','full_service','parts_only','labor_only','priority_response') NOT NULL DEFAULT 'preventive',
`visits_per_year` INT UNSIGNED NOT NULL DEFAULT 2,
`visits_used` INT UNSIGNED NOT NULL DEFAULT 0,
`annual_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`billing_frequency` ENUM('monthly','quarterly','semi_annual','annual') NOT NULL DEFAULT 'annual',
`start_date` DATE NOT NULL,
`end_date` DATE DEFAULT NULL,
`auto_renew` TINYINT NOT NULL DEFAULT 1,
`sla_response_hours` INT UNSIGNED DEFAULT NULL COMMENT 'Guaranteed response time',
`parts_discount_pct` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
`labor_discount_pct` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
`status` ENUM('active','expired','cancelled','pending_renewal') NOT NULL DEFAULT 'active',
`equipment_covered` JSON DEFAULT NULL COMMENT 'List of equipment IDs covered',
`notes` TEXT,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_number` (`agreement_number`),
KEY `idx_contact` (`contact_id`),
KEY `idx_location` (`location_id`),
KEY `idx_status` (`status`),
KEY `idx_end` (`end_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Equipment — customer equipment tracked for service history
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_equipment` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`location_id` INT UNSIGNED NOT NULL,
`contact_id` INT NOT NULL,
`equipment_type` ENUM('water_heater','furnace','ac_unit','heat_pump','boiler','electrical_panel','generator','sump_pump','water_softener','tankless_heater','mini_split','rooftop_unit','compressor','chiller','other') NOT NULL DEFAULT 'other',
`make` VARCHAR(100) DEFAULT NULL,
`model` VARCHAR(100) DEFAULT NULL,
`serial_number` VARCHAR(100) DEFAULT NULL,
`install_date` DATE DEFAULT NULL,
`warranty_expiry` DATE DEFAULT NULL,
`last_service_date` DATE DEFAULT NULL,
`next_service_date` DATE DEFAULT NULL,
`condition` ENUM('excellent','good','fair','poor','needs_replacement') DEFAULT NULL,
`location_detail` VARCHAR(255) DEFAULT NULL COMMENT 'e.g., "basement", "roof", "garage"',
`refrigerant_type` VARCHAR(20) DEFAULT NULL COMMENT 'HVAC: R-410A, R-22, etc.',
`capacity` VARCHAR(50) DEFAULT NULL COMMENT 'e.g., "50 gal", "3 ton", "200 amp"',
`fuel_type` VARCHAR(20) DEFAULT NULL COMMENT 'gas, electric, oil, propane',
`notes` TEXT,
`photo_path` VARCHAR(500) DEFAULT NULL,
`qr_code` VARCHAR(100) DEFAULT NULL COMMENT 'QR code on equipment sticker for quick lookup',
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_location` (`location_id`),
KEY `idx_contact` (`contact_id`),
KEY `idx_type` (`equipment_type`),
KEY `idx_serial` (`serial_number`),
KEY `idx_qr` (`qr_code`),
KEY `idx_next_service` (`next_service_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Vehicles — service fleet tracking
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_vehicles` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`vehicle_number` VARCHAR(20) NOT NULL,
`make` VARCHAR(50) DEFAULT NULL,
`model` VARCHAR(50) DEFAULT NULL,
`year` INT UNSIGNED DEFAULT NULL,
`vin` VARCHAR(17) DEFAULT NULL,
`license_plate` VARCHAR(20) DEFAULT NULL,
`assigned_tech_id` INT UNSIGNED DEFAULT NULL,
`status` ENUM('active','maintenance','retired') NOT NULL DEFAULT 'active',
`mileage` INT UNSIGNED DEFAULT NULL,
`last_inspection` DATE DEFAULT NULL,
`next_inspection` DATE DEFAULT NULL,
`insurance_expiry` DATE DEFAULT NULL,
`gps_device_id` VARCHAR(100) DEFAULT NULL,
`notes` TEXT,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_tech` (`assigned_tech_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Truck Stock — parts inventory per vehicle
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_truck_stock` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`vehicle_id` INT UNSIGNED NOT NULL,
`product_id` INT UNSIGNED NOT NULL COMMENT 'FK to CRM products',
`quantity` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`min_quantity` DECIMAL(10,2) NOT NULL DEFAULT 1.00 COMMENT 'Reorder point',
`last_restocked` DATE DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_vehicle_product` (`vehicle_id`, `product_id`),
KEY `idx_vehicle` (`vehicle_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Dispatch Log — assignment and routing history
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_dispatch_log` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`work_order_id` INT UNSIGNED NOT NULL,
`technician_id` INT UNSIGNED NOT NULL,
`action` ENUM('assigned','accepted','rejected','en_route','arrived','completed','reassigned','cancelled') NOT NULL,
`notes` TEXT,
`latitude` DECIMAL(10,7) DEFAULT NULL,
`longitude` DECIMAL(10,7) DEFAULT NULL,
`created_by` INT NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_wo` (`work_order_id`),
KEY `idx_tech` (`technician_id`),
KEY `idx_action` (`action`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Estimates — on-site quoting
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_estimates` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`work_order_id` INT UNSIGNED DEFAULT NULL COMMENT 'Generated from a WO inspection',
`contact_id` INT NOT NULL,
`location_id` INT UNSIGNED DEFAULT NULL,
`estimate_number` VARCHAR(30) NOT NULL DEFAULT '',
`title` VARCHAR(255) NOT NULL,
`description` TEXT,
`parts_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`labor_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`tax` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`status` ENUM('draft','sent','viewed','accepted','declined','expired','converted') NOT NULL DEFAULT 'draft',
`valid_days` INT UNSIGNED NOT NULL DEFAULT 30,
`token` VARCHAR(64) DEFAULT NULL COMMENT 'Public view/accept token',
`accepted_at` DATETIME DEFAULT NULL,
`customer_signature` TEXT,
`converted_wo_id` INT UNSIGNED DEFAULT NULL COMMENT 'WO created from accepted estimate',
`created_by` INT NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_number` (`estimate_number`),
KEY `idx_contact` (`contact_id`),
KEY `idx_wo` (`work_order_id`),
KEY `idx_status` (`status`),
KEY `idx_token` (`token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Time Entries — tech labor tracking per work order
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_time_entries` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`work_order_id` INT UNSIGNED NOT NULL,
`technician_id` INT UNSIGNED NOT NULL,
`start_time` DATETIME NOT NULL,
`end_time` DATETIME DEFAULT NULL,
`hours` DECIMAL(5,2) DEFAULT NULL,
`is_overtime` TINYINT NOT NULL DEFAULT 0,
`is_travel` TINYINT NOT NULL DEFAULT 0,
`rate` DECIMAL(10,2) DEFAULT NULL,
`notes` TEXT,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_wo` (`work_order_id`),
KEY `idx_tech` (`technician_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -1,12 +0,0 @@
DROP TABLE IF EXISTS `#__mokosuitefield_time_entries`;
DROP TABLE IF EXISTS `#__mokosuitefield_dispatch_log`;
DROP TABLE IF EXISTS `#__mokosuitefield_estimates`;
DROP TABLE IF EXISTS `#__mokosuitefield_truck_stock`;
DROP TABLE IF EXISTS `#__mokosuitefield_vehicles`;
DROP TABLE IF EXISTS `#__mokosuitefield_wo_photos`;
DROP TABLE IF EXISTS `#__mokosuitefield_wo_items`;
DROP TABLE IF EXISTS `#__mokosuitefield_work_orders`;
DROP TABLE IF EXISTS `#__mokosuitefield_agreements`;
DROP TABLE IF EXISTS `#__mokosuitefield_equipment`;
DROP TABLE IF EXISTS `#__mokosuitefield_locations`;
DROP TABLE IF EXISTS `#__mokosuitefield_technicians`;
@@ -1,132 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Customer feedback — post-service surveys, NPS scores, satisfaction tracking.
*/
class CustomerFeedbackHelper
{
/**
* Send a feedback request after work order completion.
*/
public static function requestFeedback(int $woId): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('wo.id, wo.wo_number, wo.contact_id, cd.name AS customer_name, cd.email_to, cd.telephone')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
->where('wo.id = ' . (int) $woId));
$wo = $db->loadObject();
if (!$wo || !$wo->email_to) {
return (object) ['success' => false, 'error' => 'No email for feedback request'];
}
// Generate unique feedback token
$token = bin2hex(random_bytes(16));
$request = (object) [
'wo_id' => $woId,
'contact_id' => $wo->contact_id,
'token' => $token,
'status' => 'sent',
'sent_at' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuitefield_feedback_requests', $request, 'id');
return (object) ['success' => true, 'token' => $token, 'email' => $wo->email_to];
}
/**
* Submit feedback (called from public survey page via token).
*/
public static function submitFeedback(string $token, int $rating, int $npsScore, string $comments = ''): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
// Validate inputs before DB access
$rating = max(1, min(5, $rating));
$npsScore = max(0, min(10, $npsScore));
$filter = \Joomla\Filter\InputFilter::getInstance();
$comments = $filter->clean($comments, 'STRING');
$db->transactionStart();
try {
// Lock the request row to prevent race condition on token reuse
$db->setQuery('SELECT id, wo_id, contact_id, status FROM #__mokosuitefield_feedback_requests WHERE '
. $db->quoteName('token') . ' = ' . $db->quote($token) . ' FOR UPDATE');
$request = $db->loadObject();
if (!$request) {
$db->transactionRollback();
return (object) ['success' => false, 'error' => 'Invalid feedback link'];
}
if ($request->status === 'completed') {
$db->transactionRollback();
return (object) ['success' => false, 'error' => 'Feedback already submitted'];
}
$feedback = (object) [
'request_id' => $request->id,
'wo_id' => $request->wo_id,
'contact_id' => $request->contact_id,
'rating' => $rating,
'nps_score' => $npsScore,
'comments' => $comments,
'submitted_at' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuitefield_feedback', $feedback);
$db->setQuery($db->getQuery(true)
->update('#__mokosuitefield_feedback_requests')
->set($db->quoteName('status') . ' = ' . $db->quote('completed'))
->where('id = ' . (int) $request->id));
$db->execute();
$db->transactionCommit();
} catch (\Throwable $e) {
$db->transactionRollback();
// Don't leak internal errors to public users
return (object) ['success' => false, 'error' => 'Unable to save feedback. Please try again.'];
}
return (object) ['success' => true, 'rating' => $rating, 'nps' => $npsScore];
}
/**
* Get NPS score and satisfaction summary.
*/
public static function getSatisfactionSummary(string $from = '', string $to = ''): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$from = $from ?: date('Y-01-01');
$to = $to ?: date('Y-m-d');
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS total_responses')
->select('COALESCE(AVG(rating), 0) AS avg_rating')
->select('COALESCE(AVG(nps_score), 0) AS avg_nps')
->select('SUM(CASE WHEN nps_score >= 9 THEN 1 ELSE 0 END) AS promoters')
->select('SUM(CASE WHEN nps_score BETWEEN 7 AND 8 THEN 1 ELSE 0 END) AS passives')
->select('SUM(CASE WHEN nps_score <= 6 THEN 1 ELSE 0 END) AS detractors')
->from('#__mokosuitefield_feedback')
->where('DATE(submitted_at) BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)));
$stats = $db->loadObject() ?: (object) ['total_responses' => 0, 'avg_rating' => 0, 'avg_nps' => 0, 'promoters' => 0, 'passives' => 0, 'detractors' => 0];
$total = (int) $stats->total_responses;
$stats->nps_score = $total > 0
? round(((int) $stats->promoters - (int) $stats->detractors) / $total * 100)
: 0;
return $stats;
}
}
@@ -1,118 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Customer satisfaction — post-service surveys, NPS scoring, technician ratings.
*/
class CustomerSatisfactionHelper
{
/**
* Record a post-service survey response.
*/
public static function recordSurvey(int $workOrderId, int $contactId, int $rating, ?string $comment = null): object
{
if ($rating < 1 || $rating > 5) {
throw new \InvalidArgumentException('Rating must be 1-5.');
}
$db = Factory::getContainer()->get(DatabaseInterface::class);
// Prevent duplicate surveys per work order
$db->setQuery($db->getQuery(true)
->select('id')
->from('#__mokosuitefield_surveys')
->where('work_order_id = ' . (int) $workOrderId)
->where('contact_id = ' . (int) $contactId));
if ($db->loadResult()) {
return (object) ['success' => false, 'error' => 'Survey already submitted for this work order'];
}
$filter = \Joomla\Filter\InputFilter::getInstance();
$survey = (object) [
'work_order_id' => $workOrderId,
'contact_id' => $contactId,
'rating' => $rating,
'comment' => $comment !== null ? $filter->clean($comment, 'STRING') : null,
'nps_score' => $rating >= 4 ? 'promoter' : ($rating >= 3 ? 'passive' : 'detractor'),
'created_at' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuitefield_surveys', $survey, 'id');
return (object) ['success' => true, 'survey_id' => (int) $survey->id];
}
/**
* Get NPS (Net Promoter Score) for a period.
*/
public static function getNps(string $from = '', string $to = ''): object
{
$from = $from ?: date('Y-01-01');
$to = $to ?: date('Y-m-d');
if (!\DateTime::createFromFormat('Y-m-d', $from) || !\DateTime::createFromFormat('Y-m-d', $to)) {
throw new \InvalidArgumentException('Date parameters must be Y-m-d format.');
}
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS total_responses')
->select('SUM(CASE WHEN rating >= 4 THEN 1 ELSE 0 END) AS promoters')
->select('SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END) AS passives')
->select('SUM(CASE WHEN rating <= 2 THEN 1 ELSE 0 END) AS detractors')
->select('AVG(rating) AS avg_rating')
->from('#__mokosuitefield_surveys')
->where('DATE(created_at) BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)));
$stats = $db->loadObject();
$total = (int) ($stats->total_responses ?? 0);
$promoterPct = $total > 0 ? (int) $stats->promoters / $total * 100 : 0;
$detractorPct = $total > 0 ? (int) $stats->detractors / $total * 100 : 0;
return (object) [
'nps' => round($promoterPct - $detractorPct),
'total_responses' => $total,
'promoters' => (int) ($stats->promoters ?? 0),
'passives' => (int) ($stats->passives ?? 0),
'detractors' => (int) ($stats->detractors ?? 0),
'avg_rating' => round((float) ($stats->avg_rating ?? 0), 1),
];
}
/**
* Get technician satisfaction rankings.
*/
public static function getTechnicianRankings(int $limit = 20): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('t.id AS tech_id, cd.name AS tech_name')
->select('COUNT(s.id) AS survey_count')
->select('AVG(s.rating) AS avg_rating')
->select('SUM(CASE WHEN s.rating >= 4 THEN 1 ELSE 0 END) AS five_star_count')
->from($db->quoteName('#__mokosuitefield_surveys', 's'))
->join('INNER', $db->quoteName('#__mokosuitefield_work_orders', 'wo') . ' ON wo.id = s.work_order_id')
->join('INNER', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->group('t.id, cd.name')
->having('COUNT(s.id) >= 3')
->order('avg_rating DESC'), 0, min(max(1, $limit), 100));
$results = $db->loadObjectList() ?: [];
foreach ($results as &$r) {
$r->avg_rating = round((float) $r->avg_rating, 1);
}
return $results;
}
}
@@ -1,144 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Dispatch helper — assign techs to jobs based on location, skills, availability.
*/
class DispatchHelper
{
/**
* Find the best available technician for a work order.
* Considers: trade match, distance, current workload, skills.
*/
public static function findBestTech(string $trade, string $zip, ?array $requiredSkills = null): ?object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select('t.*, cd.name AS tech_name, cd.telephone')
->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.status IN (' . $db->quote('dispatched') . ',' . $db->quote('en_route') . ',' . $db->quote('on_site') . ',' . $db->quote('in_progress') . ') AND wo.scheduled_date = CURDATE()) AS today_jobs')
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->where($db->quoteName('t.status') . ' = ' . $db->quote('available'))
->where('(' . $db->quoteName('t.trade') . ' = ' . $db->quote($trade) . ' OR ' . $db->quoteName('t.trade') . ' = ' . $db->quote('multi_trade') . ')')
->having('today_jobs < t.max_daily_jobs')
->order('today_jobs ASC');
$db->setQuery($query);
$techs = $db->loadObjectList() ?: [];
if (empty($techs)) return null;
// If skills required, filter
if ($requiredSkills) {
$techs = array_filter($techs, function ($t) use ($requiredSkills) {
$techSkills = json_decode($t->skills ?? '[]', true) ?: [];
foreach ($requiredSkills as $skill) {
if (!in_array($skill, $techSkills)) return false;
}
return true;
});
}
return !empty($techs) ? reset($techs) : null;
}
/**
* Dispatch a work order to a technician.
*/
public static function dispatch(int $workOrderId, int $technicianId): bool
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
$db->updateObject('#__mokosuitefield_work_orders', (object) [
'id' => $workOrderId,
'technician_id' => $technicianId,
'status' => 'dispatched',
'modified' => $now,
], 'id');
$db->updateObject('#__mokosuitefield_technicians', (object) [
'id' => $technicianId,
'status' => 'dispatched',
], 'id');
// Log dispatch
$db->insertObject('#__mokosuitefield_dispatch_log', (object) [
'work_order_id' => $workOrderId,
'technician_id' => $technicianId,
'action' => 'assigned',
'created_by' => Factory::getApplication()->getIdentity()->id,
'created' => $now,
]);
return true;
}
/**
* Get today's dispatch board — all jobs organized by tech.
*/
public static function getDispatchBoard(string $date = ''): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$date = $date ?: date('Y-m-d');
$db->setQuery($db->getQuery(true)
->select('t.id AS tech_id, cd.name AS tech_name, t.status AS tech_status, t.trade')
->select('wo.id AS wo_id, wo.wo_number, wo.status AS wo_status, wo.priority, wo.category')
->select('wo.scheduled_time_start, wo.scheduled_time_end')
->select('loc.address, loc.city, cust.name AS customer_name')
->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 = ' . $db->quote($date) . ' AND wo.status NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ')')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cust') . ' ON cust.id = wo.contact_id')
->order('t.id ASC, wo.scheduled_time_start ASC'));
$rows = $db->loadObjectList() ?: [];
// Group by tech
$board = [];
foreach ($rows as $row) {
$techId = (int) $row->tech_id;
if (!isset($board[$techId])) {
$board[$techId] = (object) [
'tech_id' => $techId,
'tech_name' => $row->tech_name,
'status' => $row->tech_status,
'trade' => $row->trade,
'jobs' => [],
];
}
if ($row->wo_id) {
$board[$techId]->jobs[] = $row;
}
}
return array_values($board);
}
/**
* Get unassigned work orders.
*/
public static function getUnassigned(): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('wo.*, cd.name AS customer_name, loc.address, loc.city, loc.zip')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
->where($db->quoteName('wo.technician_id') . ' IS NULL')
->where($db->quoteName('wo.status') . ' = ' . $db->quote('new'))
->order('FIELD(wo.priority, ' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ',' . $db->quote('low') . ',' . $db->quote('scheduled') . ') ASC, wo.created ASC'));
return $db->loadObjectList() ?: [];
}
}
@@ -1,77 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Equipment tracking — customer equipment with service history.
*/
class EquipmentHelper
{
public static function getLocationEquipment(int $locationId): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('*')
->from('#__mokosuitefield_equipment')
->where('location_id = ' . $locationId)
->order('equipment_type ASC, make ASC'));
return $db->loadObjectList() ?: [];
}
public static function getByQrCode(string $qrCode): ?object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)->select('e.*, loc.address, loc.city, cd.name AS owner_name')
->from($db->quoteName('#__mokosuitefield_equipment', 'e'))
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
->where($db->quoteName('e.qr_code') . ' = ' . $db->quote($qrCode)));
return $db->loadObject() ?: null;
}
public static function getDueForService(int $daysAhead = 30): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('e.*, loc.address, loc.city, cd.name AS owner_name')
->from($db->quoteName('#__mokosuitefield_equipment', 'e'))
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
->where($db->quoteName('e.next_service_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)')
->order('e.next_service_date ASC'));
return $db->loadObjectList() ?: [];
}
public static function getWarrantyExpiring(int $daysAhead = 90): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('e.*, loc.address, cd.name AS owner_name')
->from($db->quoteName('#__mokosuitefield_equipment', 'e'))
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
->where($db->quoteName('e.warranty_expiry') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)')
->order('e.warranty_expiry ASC'));
return $db->loadObjectList() ?: [];
}
public static function recordService(int $equipmentId): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->updateObject('#__mokosuitefield_equipment', (object) [
'id' => $equipmentId, 'last_service_date' => date('Y-m-d'), 'modified' => Factory::getDate()->toSql(),
], 'id');
}
}
@@ -1,83 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Uri\Uri;
use Joomla\Database\DatabaseInterface;
class EstimateHelper
{
public static function create(int $contactId, string $title, array $data = []): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
$seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuitefield_estimates'))->loadResult() + 1;
$estimate = (object) [
'estimate_number' => 'EST-' . date('Ymd') . '-' . str_pad($seq, 4, '0', STR_PAD_LEFT),
'contact_id' => $contactId,
'location_id' => (int) ($data['location_id'] ?? 0) ?: null,
'work_order_id' => (int) ($data['work_order_id'] ?? 0) ?: null,
'title' => $title,
'description' => $data['description'] ?? '',
'total' => (float) ($data['total'] ?? 0),
'status' => 'draft',
'valid_days' => (int) ($data['valid_days'] ?? 30),
'token' => bin2hex(random_bytes(32)),
'created_by' => Factory::getApplication()->getIdentity()->id,
'created' => $now,
];
$db->insertObject('#__mokosuitefield_estimates', $estimate, 'id');
return (int) $estimate->id;
}
public static function send(int $estimateId): bool
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('e.*, cd.name AS customer_name, cd.email_to')
->from($db->quoteName('#__mokosuitefield_estimates', 'e'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
->where('e.id = ' . $estimateId));
$est = $db->loadObject();
if (!$est || !$est->email_to) return false;
$url = Uri::root() . 'index.php?option=com_mokosuitefield&view=estimate&token=' . $est->token;
$mailer = Factory::getMailer();
$mailer->addRecipient($est->email_to, $est->customer_name);
$mailer->setSubject('Estimate ' . $est->estimate_number);
$mailer->setBody("Estimate ready for review:\n{$url}\n\nTotal: \$" . number_format((float) $est->total, 2));
$result = $mailer->Send();
if ($result) {
$db->updateObject('#__mokosuitefield_estimates', (object) ['id' => $estimateId, 'status' => 'sent'], 'id');
}
return (bool) $result;
}
public static function accept(string $token, ?string $signature = null): ?int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuitefield_estimates')
->where($db->quoteName('token') . ' = ' . $db->quote($token))
->where($db->quoteName('status') . ' IN (' . $db->quote('sent') . ',' . $db->quote('viewed') . ')'));
$est = $db->loadObject();
if (!$est) return null;
$db->updateObject('#__mokosuitefield_estimates', (object) [
'id' => $est->id, 'status' => 'accepted', 'accepted_at' => Factory::getDate()->toSql(),
'customer_signature' => $signature,
], 'id');
$woId = WorkOrderHelper::create((int) $est->contact_id, 'general', $est->title, [
'location_id' => $est->location_id, 'source' => 'website',
]);
$db->updateObject('#__mokosuitefield_estimates', (object) ['id' => $est->id, 'status' => 'converted', 'converted_wo_id' => $woId], 'id');
return $woId;
}
}
@@ -1,112 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* GPS tracking — vehicle location history, geofence alerts, drive time analysis.
*/
class GpsTrackingHelper
{
/**
* Record a GPS ping for a vehicle.
*/
public static function recordPing(int $vehicleId, float $latitude, float $longitude, float $speed = 0): bool
{
if ($latitude < -90 || $latitude > 90 || $longitude < -180 || $longitude > 180) {
throw new \InvalidArgumentException('Invalid GPS coordinates.');
}
$db = Factory::getContainer()->get(DatabaseInterface::class);
$ping = (object) [
'vehicle_id' => $vehicleId,
'latitude' => round($latitude, 6),
'longitude' => round($longitude, 6),
'speed_mph' => max(0, round($speed, 1)),
'recorded_at'=> Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuitefield_gps_pings', $ping);
return true;
}
/**
* Get latest position for all active vehicles.
*/
public static function getFleetPositions(): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('v.id AS vehicle_id, v.name AS vehicle_name, v.license_plate')
->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at')
->select('cd.name AS assigned_tech')
->from($db->quoteName('#__mokosuitefield_vehicles', 'v'))
->join('LEFT', '(SELECT g1.* FROM #__mokosuitefield_gps_pings g1'
. ' INNER JOIN (SELECT vehicle_id, MAX(recorded_at) AS max_at'
. ' FROM #__mokosuitefield_gps_pings GROUP BY vehicle_id) g2'
. ' ON g1.vehicle_id = g2.vehicle_id AND g1.recorded_at = g2.max_at) AS gp'
. ' ON gp.vehicle_id = v.id')
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.vehicle_id = v.id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->where($db->quoteName('v.status') . ' = ' . $db->quote('active'))
->order('v.name ASC'));
return $db->loadObjectList() ?: [];
}
/**
* Get drive history for a vehicle on a specific date.
*/
public static function getDriveHistory(int $vehicleId, string $date = ''): array
{
$date = $date ?: date('Y-m-d');
if (!\DateTime::createFromFormat('Y-m-d', $date)) {
throw new \InvalidArgumentException('Date must be Y-m-d format.');
}
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at')
->from($db->quoteName('#__mokosuitefield_gps_pings', 'gp'))
->where('gp.vehicle_id = ' . (int) $vehicleId)
->where('DATE(gp.recorded_at) = ' . $db->quote($date))
->order('gp.recorded_at ASC'));
return $db->loadObjectList() ?: [];
}
/**
* Get vehicles currently exceeding speed threshold.
*/
public static function getSpeeding(float $thresholdMph = 70): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('v.id AS vehicle_id, v.name AS vehicle_name, v.license_plate')
->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at')
->select('cd.name AS assigned_tech')
->from($db->quoteName('#__mokosuitefield_vehicles', 'v'))
->join('INNER', '(SELECT g1.* FROM #__mokosuitefield_gps_pings g1'
. ' INNER JOIN (SELECT vehicle_id, MAX(recorded_at) AS max_at'
. ' FROM #__mokosuitefield_gps_pings GROUP BY vehicle_id) g2'
. ' ON g1.vehicle_id = g2.vehicle_id AND g1.recorded_at = g2.max_at) AS gp'
. ' ON gp.vehicle_id = v.id')
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.vehicle_id = v.id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->where($db->quoteName('v.status') . ' = ' . $db->quote('active'))
->where('gp.speed_mph > ' . (float) $thresholdMph)
->where('gp.recorded_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE)')
->order('gp.speed_mph DESC'));
return $db->loadObjectList() ?: [];
}
}
@@ -1,151 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Field service invoicing — generate invoices from completed work orders with labor + parts.
*/
class InvoiceHelper
{
/**
* Generate a CRM invoice from a completed work order.
*/
public static function generateFromWorkOrder(int $woId): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
$woId = (int) $woId;
// Load work order
$db->setQuery($db->getQuery(true)
->select('wo.*, cd.name AS customer_name')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
->where('wo.id = ' . $woId));
$wo = $db->loadObject();
if (!$wo) throw new \RuntimeException('Work order not found: ' . $woId);
$db->transactionStart();
$params = Factory::getApplication()->getParams('com_mokosuitefield');
$laborRate = (float) $params->get('default_labor_rate', 85.00);
// Get time entries
$db->setQuery($db->getQuery(true)
->select('*')
->from('#__mokosuitefield_time_entries')
->where('wo_id = ' . $woId));
$timeEntries = $db->loadObjectList() ?: [];
$totalHours = 0;
foreach ($timeEntries as $te) {
$totalHours += (float) $te->hours;
}
// Get parts used
$db->setQuery($db->getQuery(true)
->select('wi.*, p.title AS part_name, p.price')
->from($db->quoteName('#__mokosuitefield_wo_items', 'wi'))
->join('LEFT', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = wi.product_id')
->where('wi.wo_id = ' . $woId));
$parts = $db->loadObjectList() ?: [];
$partsTotal = 0;
foreach ($parts as $part) {
$partsTotal += (float) ($part->price ?? 0) * (int) $part->quantity;
}
$laborTotal = round($totalHours * $laborRate, 2);
$subtotal = $laborTotal + $partsTotal;
$taxRate = (float) $params->get('default_tax_rate', 0.07);
$tax = round($subtotal * $taxRate, 2);
$total = $subtotal + $tax;
// Create CRM invoice
$seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuite_crm_invoices'))->loadResult() + 1;
$invoice = (object) [
'contact_id' => $wo->contact_id,
'invoice_number' => 'FSI-' . date('Ymd') . '-' . str_pad($seq, 4, '0', STR_PAD_LEFT),
'type' => 'standard',
'subtotal' => $subtotal,
'tax' => $tax,
'total' => $total,
'balance_due' => $total,
'status' => 'draft',
'due_date' => date('Y-m-d', strtotime('+30 days')),
'notes' => 'Field Service Invoice for WO #' . ($wo->wo_number ?? $woId),
'created' => $now,
'created_by' => Factory::getApplication()->getIdentity()->id,
];
$db->insertObject('#__mokosuite_crm_invoices', $invoice, 'id');
$invoiceId = (int) $invoice->id;
// Add labor line item
if ($totalHours > 0) {
$db->insertObject('#__mokosuite_crm_invoice_items', (object) [
'invoice_id' => $invoiceId,
'description' => 'Labor — ' . number_format($totalHours, 1) . ' hours @ $' . number_format($laborRate, 2) . '/hr',
'quantity' => $totalHours,
'unit_price' => $laborRate,
'line_total' => $laborTotal,
]);
}
// Add parts line items
foreach ($parts as $part) {
$db->insertObject('#__mokosuite_crm_invoice_items', (object) [
'invoice_id' => $invoiceId,
'product_id' => $part->product_id,
'description' => $part->part_name ?? 'Part',
'quantity' => $part->quantity,
'unit_price' => $part->price ?? 0,
'line_total' => round((float) ($part->price ?? 0) * (int) $part->quantity, 2),
]);
}
// Link invoice to work order
$db->setQuery($db->getQuery(true)
->update('#__mokosuitefield_work_orders')
->set('invoice_id = ' . $invoiceId)
->where('id = ' . $woId));
$db->execute();
$db->transactionCommit();
return $invoiceId;
}
/**
* Batch generate invoices for all completed, uninvoiced work orders.
*/
public static function batchGenerate(): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('id')
->from('#__mokosuitefield_work_orders')
->where($db->quoteName('status') . ' = ' . $db->quote('completed'))
->where('invoice_id IS NULL OR invoice_id = 0')
->order('completed_at ASC'));
$woIds = $db->loadColumn() ?: [];
$results = [];
foreach ($woIds as $woId) {
try {
$invoiceId = self::generateFromWorkOrder((int) $woId);
$results[] = ['wo_id' => $woId, 'invoice_id' => $invoiceId, 'success' => true];
} catch (\Throwable $e) {
$results[] = ['wo_id' => $woId, 'error' => $e->getMessage(), 'success' => false];
}
}
return $results;
}
}
@@ -1,107 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Parts management — common parts lookup, usage tracking, reorder alerts, cost tracking.
*/
class PartsHelper
{
/**
* Get frequently used parts by trade for quick-add on work orders.
*/
public static function getCommonParts(string $trade = '', int $limit = 20): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select('p.id, p.title AS name, p.sku, p.price, p.cost_price, p.stock_quantity')
->select('COUNT(wi.id) AS usage_count')
->from($db->quoteName('#__mokosuite_crm_products', 'p'))
->join('LEFT', $db->quoteName('#__mokosuitefield_wo_items', 'wi') . ' ON wi.product_id = p.id')
->where($db->quoteName('p.published') . ' = 1')
->group('p.id')
->order('usage_count DESC');
if ($trade) {
$query->join('LEFT', $db->quoteName('#__mokosuitefield_work_orders', 'wo') . ' ON wo.id = wi.wo_id')
->where($db->quoteName('wo.trade') . ' = ' . $db->quote($trade));
}
$db->setQuery($query, 0, $limit);
return $db->loadObjectList() ?: [];
}
/**
* Record part usage on a work order.
*/
public static function usePart(int $woId, int $productId, int $quantity, ?float $unitPrice = null): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
if ($unitPrice === null) {
$db->setQuery($db->getQuery(true)->select('price')->from('#__mokosuite_crm_products')->where('id = ' . (int) $productId));
$unitPrice = (float) $db->loadResult();
}
$item = (object) [
'wo_id' => $woId,
'product_id' => $productId,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'line_total' => round($unitPrice * $quantity, 2),
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuitefield_wo_items', $item, 'id');
// Deduct from stock
$db->setQuery($db->getQuery(true)
->update('#__mokosuite_crm_products')
->set('stock_quantity = stock_quantity - ' . (int) $quantity)
->where('id = ' . (int) $productId));
$db->execute();
return (int) $item->id;
}
/**
* Get parts that are low on stock and frequently used in field service.
*/
public static function getLowStockParts(int $limit = 20): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('p.id, p.title AS name, p.sku, p.stock_quantity, p.reorder_point, p.cost_price')
->select('(SELECT COUNT(*) FROM #__mokosuitefield_wo_items wi WHERE wi.product_id = p.id) AS field_usage')
->from($db->quoteName('#__mokosuite_crm_products', 'p'))
->where($db->quoteName('p.stock_quantity') . ' <= ' . $db->quoteName('p.reorder_point'))
->where($db->quoteName('p.reorder_point') . ' > 0')
->where('p.id IN (SELECT DISTINCT product_id FROM #__mokosuitefield_wo_items)')
->order('(p.reorder_point - p.stock_quantity) DESC'), 0, $limit);
return $db->loadObjectList() ?: [];
}
/**
* Get parts cost summary for a work order.
*/
public static function getWoPartsCost(int $woId): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS item_count')
->select('SUM(quantity) AS total_qty')
->select('COALESCE(SUM(line_total), 0) AS total_cost')
->from('#__mokosuitefield_wo_items')
->where('wo_id = ' . (int) $woId));
return $db->loadObject() ?: (object) ['item_count' => 0, 'total_qty' => 0, 'total_cost' => 0];
}
}
@@ -1,239 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Route optimization helper — build daily routes for technicians, reorder stops, calculate drive time.
*/
class RouteHelper
{
private const AVG_SPEED_MPH = 30;
/**
* Get today's route for a technician (work orders sorted by scheduled time).
*/
public static function getTechRoute(int $techId, string $date = ''): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$date = $date ?: date('Y-m-d');
$db->setQuery($db->getQuery(true)
->select('wo.id, wo.wo_number, wo.priority, wo.status, wo.trade')
->select('wo.scheduled_date, wo.scheduled_time, wo.estimated_duration')
->select('wo.route_order')
->select('l.name AS location_name, l.address, l.city, l.state, l.zip')
->select('l.latitude, l.longitude')
->select('cd.name AS customer_name, cd.telephone AS customer_phone')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'l') . ' ON l.id = wo.location_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
->where($db->quoteName('wo.technician_id') . ' = ' . (int) $techId)
->where($db->quoteName('wo.scheduled_date') . ' = ' . $db->quote($date))
->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('cancelled') . ',' . $db->quote('completed') . ')')
->order('wo.route_order ASC, wo.scheduled_time ASC'));
return $db->loadObjectList() ?: [];
}
/**
* Auto-assign route order based on geographic proximity (nearest-neighbor heuristic).
* Starts from the tech's home base or first WO location.
*/
public static function optimizeRoute(int $techId, string $date = ''): array
{
$stops = self::getTechRoute($techId, $date);
if (count($stops) <= 1) return $stops;
// Get tech home location
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('home_latitude, home_longitude')
->from('#__mokosuitefield_technicians')
->where('id = ' . (int) $techId));
$tech = $db->loadObject();
$currentLat = (float) ($tech->home_latitude ?? 0);
$currentLng = (float) ($tech->home_longitude ?? 0);
// Nearest-neighbor sort
$ordered = [];
$remaining = $stops;
while (!empty($remaining)) {
$bestIdx = 0;
$bestDist = PHP_FLOAT_MAX;
foreach ($remaining as $idx => $stop) {
$lat = (float) ($stop->latitude ?? 0);
$lng = (float) ($stop->longitude ?? 0);
if ($lat === 0.0 && $lng === 0.0) {
// No coordinates — keep in original position
$dist = PHP_FLOAT_MAX - 1;
} else {
$dist = self::haversine($currentLat, $currentLng, $lat, $lng);
}
if ($dist < $bestDist) {
$bestDist = $dist;
$bestIdx = $idx;
}
}
$next = $remaining[$bestIdx];
$ordered[] = $next;
$currentLat = (float) ($next->latitude ?? $currentLat);
$currentLng = (float) ($next->longitude ?? $currentLng);
array_splice($remaining, $bestIdx, 1);
$remaining = array_values($remaining);
}
// Save route order
foreach ($ordered as $i => $stop) {
$update = (object) [
'id' => $stop->id,
'route_order' => $i + 1,
];
$db->updateObject('#__mokosuitefield_work_orders', $update, 'id');
$ordered[$i]->route_order = $i + 1;
}
return $ordered;
}
/**
* Manually reorder a stop within a tech's route.
*/
public static function reorderStop(int $woId, int $newPosition): bool
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('technician_id, scheduled_date')
->from('#__mokosuitefield_work_orders')
->where('id = ' . (int) $woId));
$wo = $db->loadObject();
if (!$wo) return false;
$stops = self::getTechRoute((int) $wo->technician_id, $wo->scheduled_date);
// Find and remove the target WO
$target = null;
$filtered = [];
foreach ($stops as $stop) {
if ((int) $stop->id === $woId) {
$target = $stop;
} else {
$filtered[] = $stop;
}
}
if (!$target) return false;
// Insert at new position
$newPosition = max(1, min($newPosition, count($filtered) + 1));
array_splice($filtered, $newPosition - 1, 0, [$target]);
// Save new order
foreach ($filtered as $i => $stop) {
$update = (object) [
'id' => $stop->id,
'route_order' => $i + 1,
];
$db->updateObject('#__mokosuitefield_work_orders', $update, 'id');
}
return true;
}
/**
* Estimate total drive time and distance for a route (using straight-line approximation).
*/
public static function estimateRouteMetrics(int $techId, string $date = ''): object
{
$stops = self::getTechRoute($techId, $date);
$totalDistance = 0.0;
$totalJobTime = 0;
$legs = [];
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('home_latitude, home_longitude')
->from('#__mokosuitefield_technicians')
->where('id = ' . (int) $techId));
$tech = $db->loadObject();
$prevLat = (float) ($tech->home_latitude ?? 0);
$prevLng = (float) ($tech->home_longitude ?? 0);
foreach ($stops as $stop) {
$lat = (float) ($stop->latitude ?? 0);
$lng = (float) ($stop->longitude ?? 0);
$dist = 0;
if ($lat && $lng && ($prevLat || $prevLng)) {
$dist = self::haversine($prevLat, $prevLng, $lat, $lng);
}
$totalDistance += $dist;
$totalJobTime += (int) ($stop->estimated_duration ?? 60);
$legs[] = (object) [
'wo_id' => $stop->id,
'location' => $stop->location_name ?? $stop->address,
'distance' => round($dist, 1),
'drive_min'=> round($dist / self::AVG_SPEED_MPH * 60, 0),
];
$prevLat = $lat ?: $prevLat;
$prevLng = $lng ?: $prevLng;
}
$totalDriveMin = $totalDistance > 0 ? round($totalDistance / self::AVG_SPEED_MPH * 60) : 0;
return (object) [
'stop_count' => count($stops),
'total_distance' => round($totalDistance, 1),
'total_drive_min'=> $totalDriveMin,
'total_job_min' => $totalJobTime,
'total_day_min' => $totalDriveMin + $totalJobTime,
'legs' => $legs,
];
}
/**
* Haversine distance in miles.
*/
private static function haversine(float $lat1, float $lon1, float $lat2, float $lon2): float
{
$R = 3959; // Earth radius in miles
$dLat = deg2rad($lat2 - $lat1);
$dLon = deg2rad($lon2 - $lon1);
$a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon / 2) ** 2;
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return $R * $c;
}
/**
* Log a GPS breadcrumb for a tech (called from mobile app).
*/
public static function logGpsBreadcrumb(int $techId, float $lat, float $lng, ?int $woId = null): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$crumb = (object) [
'technician_id' => $techId,
'latitude' => $lat,
'longitude' => $lng,
'wo_id' => $woId,
'recorded_at' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuitefield_dispatch_log', $crumb);
}
}
@@ -1,147 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Safety checklists — pre-job safety checks, compliance tracking, incident prevention.
*/
class SafetyChecklistHelper
{
/**
* Create a safety checklist for a work order.
*/
public static function createChecklist(int $woId, string $trade): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
$items = self::getDefaultItems($trade);
$checklist = (object) [
'wo_id' => $woId,
'trade' => $trade,
'status' => 'pending',
'created' => $now,
'created_by' => Factory::getApplication()->getIdentity()->id,
];
$db->insertObject('#__mokosuitefield_safety_checklists', $checklist, 'id');
$checklistId = (int) $checklist->id;
foreach ($items as $i => $item) {
$db->insertObject('#__mokosuitefield_safety_checklist_items', (object) [
'checklist_id' => $checklistId,
'item_text' => $item,
'checked' => 0,
'ordering' => $i + 1,
]);
}
return $checklistId;
}
/**
* Complete a checklist item.
*/
public static function checkItem(int $itemId, bool $passed, string $notes = ''): bool
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
// Verify item belongs to a pending checklist and is not already checked
$db->setQuery($db->getQuery(true)
->select('sci.id, sci.checked, sc.status AS checklist_status')
->from($db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci'))
->join('INNER', $db->quoteName('#__mokosuitefield_safety_checklists', 'sc') . ' ON sc.id = sci.checklist_id')
->where('sci.id = ' . (int) $itemId));
$existing = $db->loadObject();
if (!$existing || $existing->checklist_status !== 'pending' || (int) $existing->checked === 1) {
return false;
}
$update = (object) [
'id' => $itemId,
'checked' => 1,
'passed' => $passed ? 1 : 0,
'notes' => $notes,
'checked_at' => Factory::getDate()->toSql(),
'checked_by' => Factory::getApplication()->getIdentity()->id,
];
$db->updateObject('#__mokosuitefield_safety_checklist_items', $update, 'id');
// Auto-complete checklist if all items are checked
$db->setQuery($db->getQuery(true)
->select('sc.id, COUNT(sci2.id) AS total, SUM(CASE WHEN sci2.checked = 1 THEN 1 ELSE 0 END) AS done')
->from($db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci2'))
->join('INNER', $db->quoteName('#__mokosuitefield_safety_checklists', 'sc') . ' ON sc.id = sci2.checklist_id')
->where('sci2.id = ' . (int) $itemId)
->group('sc.id'));
$progress = $db->loadObject();
if ($progress && (int) $progress->done === (int) $progress->total) {
$db->setQuery($db->getQuery(true)
->update('#__mokosuitefield_safety_checklists')
->set($db->quoteName('status') . ' = ' . $db->quote('completed'))
->where('id = ' . (int) $progress->id));
$db->execute();
}
return true;
}
/**
* Get checklist completion status for a work order.
*/
public static function getStatus(int $woId): ?object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('sc.id, sc.status')
->select('COUNT(sci.id) AS total_items')
->select('SUM(CASE WHEN sci.checked = 1 THEN 1 ELSE 0 END) AS checked_items')
->select('SUM(CASE WHEN sci.checked = 1 AND sci.passed = 0 THEN 1 ELSE 0 END) AS failed_items')
->from($db->quoteName('#__mokosuitefield_safety_checklists', 'sc'))
->join('LEFT', $db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci') . ' ON sci.checklist_id = sc.id')
->where('sc.wo_id = ' . (int) $woId)
->group('sc.id')
->order('sc.created DESC'));
$status = $db->loadObject();
if (!$status) return null;
$status->complete = (int) $status->checked_items === (int) $status->total_items;
$status->all_passed = (int) $status->failed_items === 0;
$status->safe_to_proceed = $status->complete && $status->all_passed;
return $status;
}
/**
* Get default safety items by trade.
*/
private static function getDefaultItems(string $trade): array
{
$common = [
'PPE worn (gloves, safety glasses, boots)',
'Work area inspected for hazards',
'Tools and equipment in good condition',
'Fire extinguisher accessible',
'Emergency exits identified',
];
$tradeSpecific = match ($trade) {
'electrical' => ['Lockout/tagout verified', 'Voltage tester functional', 'Grounding confirmed', 'Arc flash boundaries marked'],
'plumbing' => ['Water supply shut off', 'Gas lines identified and marked', 'Asbestos check for older buildings', 'Drain protection in place'],
'hvac' => ['Refrigerant handling certification verified', 'Electrical isolation confirmed', 'Ductwork supports inspected', 'Ladder/scaffold secured'],
'general' => ['Work permit obtained if required', 'Material Safety Data Sheets reviewed'],
default => [],
};
return array_merge($common, $tradeSpecific);
}
}
@@ -1,152 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Scheduling helper — tech availability, appointment slots, recurring service schedules.
*/
class SchedulingHelper
{
/**
* Get available appointment slots for a date and trade.
*/
public static function getAvailableSlots(string $date, string $trade = 'general', int $durationMin = 60): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$params = Factory::getApplication()->getParams('com_mokosuitefield');
$startHour = (int) $params->get('schedule_start_hour', 8);
$endHour = (int) $params->get('schedule_end_hour', 17);
$slotInterval = (int) $params->get('slot_interval_minutes', 30);
// Get available techs for this trade
$db->setQuery($db->getQuery(true)
->select('t.id, cd.name AS tech_name, t.max_daily_jobs')
->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.scheduled_date = ' . $db->quote($date) . ' AND wo.status NOT IN (' . $db->quote('cancelled') . ',' . $db->quote('completed') . ')) AS booked_count')
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->where($db->quoteName('t.status') . ' = ' . $db->quote('available'))
->where('(' . $db->quoteName('t.trade') . ' = ' . $db->quote($trade) . ' OR ' . $db->quoteName('t.trade') . ' = ' . $db->quote('multi_trade') . ')')
->having('booked_count < t.max_daily_jobs'));
$techs = $db->loadObjectList() ?: [];
if (empty($techs)) return [];
// Get existing WO times for these techs
$techIds = array_column($techs, 'id');
$db->setQuery($db->getQuery(true)
->select('technician_id, scheduled_time, estimated_duration')
->from('#__mokosuitefield_work_orders')
->where('scheduled_date = ' . $db->quote($date))
->where('technician_id IN (' . implode(',', array_map('intval', $techIds)) . ')')
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('cancelled') . ',' . $db->quote('completed') . ')'));
$existingWOs = $db->loadObjectList() ?: [];
// Build blocked time ranges per tech
$blocked = [];
foreach ($existingWOs as $wo) {
$start = strtotime($wo->scheduled_time);
$end = $start + ((int) ($wo->estimated_duration ?? 60)) * 60;
$blocked[$wo->technician_id][] = [$start, $end];
}
// Generate slots
$slots = [];
$current = strtotime($date . ' ' . str_pad($startHour, 2, '0', STR_PAD_LEFT) . ':00:00');
$dayEnd = strtotime($date . ' ' . str_pad($endHour, 2, '0', STR_PAD_LEFT) . ':00:00') - ($durationMin * 60);
while ($current <= $dayEnd) {
$slotEnd = $current + ($durationMin * 60);
// Find at least one available tech for this slot
$availableTechs = [];
foreach ($techs as $tech) {
$conflict = false;
foreach ($blocked[$tech->id] ?? [] as [$bStart, $bEnd]) {
if ($current < $bEnd && $slotEnd > $bStart) {
$conflict = true;
break;
}
}
if (!$conflict) {
$availableTechs[] = $tech->tech_name;
}
}
if (!empty($availableTechs)) {
$slots[] = (object) [
'time' => date('H:i', $current),
'display' => date('g:i A', $current),
'available_techs' => count($availableTechs),
];
}
$current += $slotInterval * 60;
}
return $slots;
}
/**
* Schedule a work order into a specific slot.
*/
public static function scheduleWorkOrder(int $woId, string $date, string $time, ?int $techId = null): bool
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
// Auto-assign tech if not specified
if (!$techId) {
$wo = $db->setQuery($db->getQuery(true)->select('trade')->from('#__mokosuitefield_work_orders')->where('id = ' . (int) $woId))->loadObject();
$bestTech = DispatchHelper::findBestTech($wo->trade ?? 'general', '');
$techId = $bestTech ? (int) $bestTech->id : null;
}
$update = (object) [
'id' => $woId,
'scheduled_date' => $date,
'scheduled_time' => $time,
'technician_id' => $techId,
'status' => 'scheduled',
];
return $db->updateObject('#__mokosuitefield_work_orders', $update, 'id');
}
/**
* Get today's schedule for all techs.
*/
public static function getTodaySchedule(): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('t.id AS tech_id, cd.name AS tech_name, t.trade')
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->where($db->quoteName('t.status') . ' != ' . $db->quote('inactive'))
->order('cd.name ASC'));
$techs = $db->loadObjectList() ?: [];
foreach ($techs as &$tech) {
$db->setQuery($db->getQuery(true)
->select('wo.id, wo.wo_number, wo.scheduled_time, wo.estimated_duration, wo.status, wo.priority')
->select('loc.address, loc.city')
->select('cd2.name AS customer_name')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd2') . ' ON cd2.id = wo.contact_id')
->where('wo.technician_id = ' . (int) $tech->tech_id)
->where('wo.scheduled_date = CURDATE()')
->where($db->quoteName('wo.status') . ' != ' . $db->quote('cancelled'))
->order('wo.scheduled_time ASC'));
$tech->jobs = $db->loadObjectList() ?: [];
$tech->job_count = count($tech->jobs);
}
return $techs;
}
}
@@ -1,78 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Service agreement helper — recurring maintenance contracts with SLA.
*/
class ServiceAgreementHelper
{
public static function getActiveAgreements(int $contactId = 0): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select('a.*, cd.name AS customer_name, loc.address')
->from($db->quoteName('#__mokosuitefield_agreements', 'a'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = a.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = a.location_id')
->where($db->quoteName('a.status') . ' = ' . $db->quote('active'))
->order('a.end_date ASC');
if ($contactId) $query->where('a.contact_id = ' . $contactId);
$db->setQuery($query);
$agreements = $db->loadObjectList() ?: [];
foreach ($agreements as &$a) {
$a->visits_remaining = max(0, (int) $a->visits_per_year - (int) $a->visits_used);
$a->days_until_expiry = $a->end_date ? max(0, round((strtotime($a->end_date) - time()) / 86400)) : null;
}
return $agreements;
}
public static function getExpiring(int $daysAhead = 30): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('a.*, cd.name AS customer_name, cd.email_to')
->from($db->quoteName('#__mokosuitefield_agreements', 'a'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = a.contact_id')
->where($db->quoteName('a.status') . ' = ' . $db->quote('active'))
->where($db->quoteName('a.end_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)')
->order('a.end_date ASC'));
return $db->loadObjectList() ?: [];
}
public static function getRevenueSummary(): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS active_agreements')
->select('COALESCE(SUM(annual_amount), 0) AS annual_recurring')
->select('COALESCE(SUM(annual_amount / 12), 0) AS monthly_recurring')
->from('#__mokosuitefield_agreements')
->where($db->quoteName('status') . ' = ' . $db->quote('active')));
return $db->loadObject() ?: (object) ['active_agreements' => 0, 'annual_recurring' => 0, 'monthly_recurring' => 0];
}
public static function recordVisit(int $agreementId, int $workOrderId): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->update('#__mokosuitefield_agreements')
->set('visits_used = visits_used + 1')
->where('id = ' . $agreementId));
$db->execute();
}
}
@@ -1,97 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Technician skill matrix — certifications, skill-based dispatch matching, expiry tracking.
*/
class TechnicianSkillHelper
{
/**
* Get skill matrix for all active technicians.
*/
public static function getSkillMatrix(): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('t.id AS tech_id, cd.name AS tech_name')
->select('GROUP_CONCAT(ts.skill_name ORDER BY ts.skill_name SEPARATOR ", ") AS skills')
->select('COUNT(ts.id) AS skill_count')
->select('SUM(CASE WHEN ts.certification_expires IS NOT NULL AND ts.certification_expires < NOW() THEN 1 ELSE 0 END) AS expired_certs')
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->join('LEFT', $db->quoteName('#__mokosuitefield_tech_skills', 'ts') . ' ON ts.tech_id = t.id')
->where($db->quoteName('t.status') . ' = ' . $db->quote('active'))
->group('t.id')
->order('cd.name ASC'));
return $db->loadObjectList() ?: [];
}
/**
* Find best technician match for a work order based on required skills.
*/
public static function findBestMatch(array $requiredSkills, string $date = ''): array
{
if (empty($requiredSkills)) {
return [];
}
$date = $date ?: date('Y-m-d');
if (!\DateTime::createFromFormat('Y-m-d', $date)) {
throw new \InvalidArgumentException('Date must be Y-m-d format.');
}
$db = Factory::getContainer()->get(DatabaseInterface::class);
$skillPlaceholders = implode(',', array_map(fn($s) => $db->quote($s), $requiredSkills));
$db->setQuery($db->getQuery(true)
->select('t.id AS tech_id, cd.name AS tech_name, t.hourly_rate')
->select('COUNT(DISTINCT ts.skill_name) AS matching_skills')
->select((string) count($requiredSkills) . ' AS required_skills')
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->join('INNER', $db->quoteName('#__mokosuitefield_tech_skills', 'ts')
. ' ON ts.tech_id = t.id AND ts.skill_name IN (' . $skillPlaceholders . ')'
. ' AND (ts.certification_expires IS NULL OR ts.certification_expires > ' . $db->quote($date) . ')')
->where($db->quoteName('t.status') . ' = ' . $db->quote('active'))
->group('t.id')
->order('matching_skills DESC, t.hourly_rate ASC'));
$matches = $db->loadObjectList() ?: [];
foreach ($matches as &$m) {
$m->match_pct = round((int) $m->matching_skills / (int) $m->required_skills * 100, 1);
}
return $matches;
}
/**
* Get expiring certifications within N days.
*/
public static function getExpiringCertifications(int $days = 30): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$cutoff = date('Y-m-d', strtotime("+{$days} days"));
$db->setQuery($db->getQuery(true)
->select('ts.id, ts.skill_name, ts.certification_number, ts.certification_expires')
->select('cd.name AS tech_name, cd.email_to')
->from($db->quoteName('#__mokosuitefield_tech_skills', 'ts'))
->join('INNER', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = ts.tech_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->where($db->quoteName('t.status') . ' = ' . $db->quote('active'))
->where('ts.certification_expires IS NOT NULL')
->where('ts.certification_expires BETWEEN NOW() AND ' . $db->quote($cutoff))
->order('ts.certification_expires ASC'));
return $db->loadObjectList() ?: [];
}
}
@@ -1,69 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Truck stock management — per-vehicle parts inventory.
*/
class TruckStockHelper
{
public static function getVehicleInventory(int $vehicleId): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('ts.*, p.name AS part_name, p.sku, p.cost_price')
->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts'))
->join('INNER', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id')
->where('ts.vehicle_id = ' . $vehicleId)
->order('p.name ASC'));
return $db->loadObjectList() ?: [];
}
public static function getLowStock(): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('ts.*, p.name AS part_name, p.sku, v.vehicle_number')
->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts'))
->join('INNER', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id')
->join('INNER', $db->quoteName('#__mokosuitefield_vehicles', 'v') . ' ON v.id = ts.vehicle_id')
->where('ts.quantity <= ts.min_quantity')
->order('ts.quantity ASC'));
return $db->loadObjectList() ?: [];
}
public static function usePart(int $vehicleId, int $productId, float $qty = 1): bool
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->update('#__mokosuitefield_truck_stock')
->set('quantity = quantity - ' . (float) $qty)
->where('vehicle_id = ' . (int) $vehicleId)
->where('product_id = ' . (int) $productId)
->where('quantity >= ' . (float) $qty));
$db->execute();
return $db->getAffectedRows() > 0;
}
public static function restock(int $vehicleId, int $productId, float $qty): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery(
'INSERT INTO #__mokosuitefield_truck_stock (vehicle_id, product_id, quantity, last_restocked)'
. ' VALUES (' . (int) $vehicleId . ', ' . (int) $productId . ', ' . (float) $qty . ', CURDATE())'
. ' ON DUPLICATE KEY UPDATE quantity = quantity + ' . (float) $qty . ', last_restocked = CURDATE()'
);
$db->execute();
}
}
@@ -1,44 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Fleet/vehicle management.
*/
class VehicleHelper
{
public static function getFleet(): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('v.*, cd.name AS assigned_tech_name')
->select('(SELECT COUNT(*) FROM #__mokosuitefield_truck_stock ts WHERE ts.vehicle_id = v.id AND ts.quantity <= ts.min_quantity) AS low_stock_items')
->from($db->quoteName('#__mokosuitefield_vehicles', 'v'))
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.assigned_tech_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->order('v.vehicle_number ASC'));
return $db->loadObjectList() ?: [];
}
public static function getInspectionsDue(int $daysAhead = 30): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('v.*, cd.name AS tech_name')
->from($db->quoteName('#__mokosuitefield_vehicles', 'v'))
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.assigned_tech_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->where($db->quoteName('v.status') . ' = ' . $db->quote('active'))
->where($db->quoteName('v.next_inspection') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)')
->order('v.next_inspection ASC'));
return $db->loadObjectList() ?: [];
}
}
@@ -1,102 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Equipment warranty tracking — warranty status, claim processing, expiry alerts.
*/
class WarrantyHelper
{
/**
* Check warranty status for a piece of equipment.
*/
public static function checkWarranty(int $equipmentId): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('e.id, e.serial_number, e.model, e.install_date')
->select('e.warranty_start, e.warranty_end, e.warranty_provider, e.warranty_type')
->from($db->quoteName('#__mokosuitefield_equipment', 'e'))
->where('e.id = ' . (int) $equipmentId));
$equipment = $db->loadObject();
if (!$equipment) return (object) ['found' => false];
$now = new \DateTime('today');
$warrantyEnd = $equipment->warranty_end ? new \DateTime($equipment->warranty_end) : null;
$equipment->under_warranty = $warrantyEnd && $now <= $warrantyEnd;
$equipment->days_remaining = $warrantyEnd ? max(0, (int) $now->diff($warrantyEnd)->format('%r%a')) : null;
$equipment->warranty_expired = $warrantyEnd && $now > $warrantyEnd;
// Get claim history
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS total_claims, COALESCE(SUM(claim_amount), 0) AS total_claimed')
->from('#__mokosuitefield_warranty_claims')
->where('equipment_id = ' . (int) $equipmentId));
$claims = $db->loadObject();
$equipment->total_claims = (int) ($claims->total_claims ?? 0);
$equipment->total_claimed = (float) ($claims->total_claimed ?? 0);
return $equipment;
}
/**
* Submit a warranty claim.
*/
public static function submitClaim(int $equipmentId, int $woId, string $description, float $claimAmount): object
{
$warranty = self::checkWarranty($equipmentId);
if (empty($warranty->id)) return (object) ['success' => false, 'error' => 'Equipment not found'];
if (!$warranty->under_warranty) return (object) ['success' => false, 'error' => 'Warranty expired'];
$db = Factory::getContainer()->get(DatabaseInterface::class);
// Verify work order exists and is linked to this equipment
$db->setQuery($db->getQuery(true)->select('id')->from('#__mokosuitefield_work_orders')
->where('id = ' . (int) $woId));
if (!(int) $db->loadResult()) {
return (object) ['success' => false, 'error' => 'Invalid work order'];
}
$now = Factory::getDate()->toSql();
$claim = (object) [
'equipment_id' => $equipmentId,
'wo_id' => $woId,
'description' => $description,
'claim_amount' => $claimAmount,
'status' => 'submitted',
'submitted_at' => $now,
'submitted_by' => Factory::getApplication()->getIdentity()->id,
];
$db->insertObject('#__mokosuitefield_warranty_claims', $claim, 'id');
return (object) ['success' => true, 'claim_id' => (int) $claim->id];
}
/**
* Get equipment with warranties expiring within N days.
*/
public static function getExpiringSoon(int $days = 90): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('e.*, l.name AS location_name, cd.name AS customer_name')
->from($db->quoteName('#__mokosuitefield_equipment', 'e'))
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'l') . ' ON l.id = e.location_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
->where($db->quoteName('e.warranty_end') . ' IS NOT NULL')
->where($db->quoteName('e.warranty_end') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . (int) $days . ' DAY)')
->order('e.warranty_end ASC'));
return $db->loadObjectList() ?: [];
}
}
@@ -1,162 +0,0 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Work order lifecycle helper — create, update status, complete, invoice.
*/
class WorkOrderHelper
{
public static function create(int $contactId, string $trade, string $description, array $data = []): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
// Read prefix from component config (config.xml)
$params = Factory::getApplication()->getParams('com_mokosuitefield');
$woPrefix = $params->get('wo_prefix', 'WO');
$defaultTrade = $params->get('default_trade', 'general');
if (!$trade) $trade = $defaultTrade;
$seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuitefield_work_orders'))->loadResult() + 1;
$wo = (object) [
'wo_number' => $woPrefix . '-' . date('Ymd') . '-' . str_pad($seq, 4, '0', STR_PAD_LEFT),
'contact_id' => $contactId,
'location_id' => (int) ($data['location_id'] ?? 0) ?: null,
'trade' => $trade,
'priority' => $data['priority'] ?? 'normal',
'status' => 'new',
'category' => $data['category'] ?? null,
'description' => $description,
'customer_po' => $data['customer_po'] ?? null,
'scheduled_date' => $data['scheduled_date'] ?? null,
'scheduled_time_start' => $data['time_start'] ?? null,
'scheduled_time_end' => $data['time_end'] ?? null,
'service_agreement_id' => (int) ($data['agreement_id'] ?? 0) ?: null,
'source' => $data['source'] ?? 'phone',
'created_by' => Factory::getApplication()->getIdentity()->id,
'created' => $now,
];
$db->insertObject('#__mokosuitefield_work_orders', $wo, 'id');
return (int) $wo->id;
}
public static function updateStatus(int $woId, string $status, ?float $lat = null, ?float $lng = null): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
$update = (object) ['id' => $woId, 'status' => $status, 'modified' => $now];
if ($status === 'on_site') $update->actual_arrival = $now;
if ($status === 'completed') $update->actual_departure = $now;
$db->updateObject('#__mokosuitefield_work_orders', $update, 'id');
// Get tech ID for dispatch log
$db->setQuery($db->getQuery(true)->select('technician_id')->from('#__mokosuitefield_work_orders')->where('id = ' . $woId));
$techId = (int) $db->loadResult();
if ($techId) {
$action = match ($status) {
'en_route' => 'en_route',
'on_site' => 'arrived',
'completed' => 'completed',
'cancelled' => 'cancelled',
default => null,
};
if ($action) {
$db->insertObject('#__mokosuitefield_dispatch_log', (object) [
'work_order_id' => $woId,
'technician_id' => $techId,
'action' => $action,
'latitude' => $lat,
'longitude' => $lng,
'created_by' => Factory::getApplication()->getIdentity()->id,
'created' => $now,
]);
}
// Update tech status
if ($status === 'completed' || $status === 'cancelled') {
$db->updateObject('#__mokosuitefield_technicians', (object) ['id' => $techId, 'status' => 'available'], 'id');
} elseif ($status === 'en_route') {
$db->updateObject('#__mokosuitefield_technicians', (object) [
'id' => $techId, 'status' => 'en_route', 'current_lat' => $lat, 'current_lng' => $lng, 'last_location_update' => $now,
], 'id');
} elseif ($status === 'on_site') {
$db->updateObject('#__mokosuitefield_technicians', (object) ['id' => $techId, 'status' => 'on_site'], 'id');
}
}
}
public static function complete(int $woId, string $workPerformed, ?string $signature = null): void
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
// Calculate totals from line items
$db->setQuery($db->getQuery(true)
->select('COALESCE(SUM(CASE WHEN item_type = ' . $db->quote('labor') . ' THEN line_total ELSE 0 END), 0) AS labor')
->select('COALESCE(SUM(CASE WHEN item_type != ' . $db->quote('labor') . ' THEN line_total ELSE 0 END), 0) AS parts')
->from('#__mokosuitefield_wo_items')
->where('work_order_id = ' . $woId));
$totals = $db->loadObject();
$laborTotal = (float) ($totals->labor ?? 0);
$partsTotal = (float) ($totals->parts ?? 0);
$total = $laborTotal + $partsTotal;
$db->updateObject('#__mokosuitefield_work_orders', (object) [
'id' => $woId,
'status' => 'completed',
'work_performed' => $workPerformed,
'parts_total' => $partsTotal,
'labor_total' => $laborTotal,
'total' => $total,
'customer_signature' => $signature,
'customer_signed_at' => $signature ? $now : null,
'actual_departure' => $now,
'modified' => $now,
], 'id');
// Update location service history
$db->setQuery($db->getQuery(true)->select('location_id')->from('#__mokosuitefield_work_orders')->where('id = ' . $woId));
$locId = (int) $db->loadResult();
if ($locId) {
$db->setQuery($db->getQuery(true)
->update('#__mokosuitefield_locations')
->set('service_history_count = service_history_count + 1')
->set('last_service_date = ' . $db->quote(date('Y-m-d')))
->where('id = ' . $locId));
$db->execute();
}
}
public static function getDashboardStats(): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$today = date('Y-m-d');
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS total_today')
->select('SUM(CASE WHEN status = ' . $db->quote('new') . ' THEN 1 ELSE 0 END) AS unassigned')
->select('SUM(CASE WHEN status IN (' . $db->quote('dispatched') . ',' . $db->quote('en_route') . ') THEN 1 ELSE 0 END) AS en_route')
->select('SUM(CASE WHEN status IN (' . $db->quote('on_site') . ',' . $db->quote('in_progress') . ') THEN 1 ELSE 0 END) AS on_site')
->select('SUM(CASE WHEN status = ' . $db->quote('completed') . ' THEN 1 ELSE 0 END) AS completed')
->select('SUM(CASE WHEN priority IN (' . $db->quote('emergency') . ',' . $db->quote('urgent') . ') THEN 1 ELSE 0 END) AS urgent')
->from('#__mokosuitefield_work_orders')
->where('scheduled_date = ' . $db->quote($today) . ' OR (scheduled_date IS NULL AND DATE(created) = ' . $db->quote($today) . ')'));
return $db->loadObject() ?: (object) ['total_today' => 0, 'unassigned' => 0, 'en_route' => 0, 'on_site' => 0, 'completed' => 0, 'urgent' => 0];
}
}
@@ -1,144 +0,0 @@
<?php
namespace Moko\Plugin\Task\MokoSuiteField\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
use Joomla\Component\Scheduler\Administrator\Task\Status;
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
use Joomla\Database\DatabaseInterface;
use Joomla\Event\SubscriberInterface;
/**
* Field service scheduled tasks: service reminders, agreement renewals,
* equipment maintenance alerts, truck stock reorder.
*/
class FieldAutomation extends CMSPlugin implements SubscriberInterface
{
use TaskPluginTrait;
protected const TASKS_MAP = [
'mokosuite.field.service.reminders' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_SERVICE_REMINDERS',
'method' => 'sendServiceReminders',
],
'mokosuite.field.agreement.renewal' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_AGREEMENT_RENEWAL',
'method' => 'checkAgreementRenewals',
],
'mokosuite.field.equipment.maintenance' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_EQUIPMENT_MAINTENANCE',
'method' => 'checkEquipmentMaintenance',
],
'mokosuite.field.truck.reorder' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_TRUCK_REORDER',
'method' => 'checkTruckStock',
],
];
public static function getSubscribedEvents(): array
{
return [
'onExecuteTask' => 'standardRoutineHandler',
'onContentPrepareForm' => 'enhanceTaskItemForm',
'onTaskOptionsList' => 'advertiseRoutines',
];
}
private function sendServiceReminders(ExecuteTaskEvent $event): int
{
$equipment = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getDueForService(14);
if (empty($equipment)) return Status::OK;
$body = "Equipment due for service (next 14 days):\n\n";
foreach ($equipment as $e) {
$body .= "- {$e->equipment_type} ({$e->make} {$e->model}) at {$e->address}, {$e->owner_name}\n";
$body .= " Due: " . date('M j, Y', strtotime($e->next_service_date)) . "\n";
}
$mailer = Factory::getMailer();
$mailer->addRecipient(Factory::getApplication()->get('mailfrom'));
$mailer->setSubject('Field Service: ' . count($equipment) . ' equipment due for maintenance');
$mailer->setBody($body);
$mailer->Send();
Log::add("Field equipment reminders: " . count($equipment) . " due", Log::INFO, 'mokosuite.field');
return Status::OK;
}
private function checkAgreementRenewals(ExecuteTaskEvent $event): int
{
$expiring = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getExpiring(30);
foreach ($expiring as $a) {
if (!$a->email_to) continue;
$mailer = Factory::getMailer();
$mailer->addRecipient($a->email_to, $a->customer_name);
$mailer->setSubject('Service Agreement Renewal — ' . $a->title);
$mailer->setBody(
"Hi {$a->customer_name},\n\n"
. "Your service agreement \"{$a->title}\" expires on " . date('F j, Y', strtotime($a->end_date)) . ".\n\n"
. "Please contact us to renew.\n"
);
$mailer->Send();
}
Log::add("Field agreement renewals: " . count($expiring) . " expiring", Log::INFO, 'mokosuite.field');
return Status::OK;
}
private function checkEquipmentMaintenance(ExecuteTaskEvent $event): int
{
$warranty = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getWarrantyExpiring(90);
if (!empty($warranty)) {
$body = "Equipment warranties expiring (90 days):\n\n";
foreach ($warranty as $e) {
$body .= "- {$e->make} {$e->model} (SN: {$e->serial_number}) — {$e->owner_name}\n";
$body .= " Warranty expires: " . date('M j, Y', strtotime($e->warranty_expiry)) . "\n";
}
$mailer = Factory::getMailer();
$mailer->addRecipient(Factory::getApplication()->get('mailfrom'));
$mailer->setSubject('Field: ' . count($warranty) . ' equipment warranties expiring');
$mailer->setBody($body);
$mailer->Send();
}
return Status::OK;
}
private function checkTruckStock(ExecuteTaskEvent $event): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('ts.*, p.name AS part_name, p.sku, v.vehicle_number')
->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts'))
->join('INNER', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id')
->join('INNER', $db->quoteName('#__mokosuitefield_vehicles', 'v') . ' ON v.id = ts.vehicle_id')
->where('ts.quantity <= ts.min_quantity'));
$low = $db->loadObjectList() ?: [];
if (!empty($low)) {
$body = "Truck stock below reorder point:\n\n";
foreach ($low as $item) {
$body .= "- Vehicle {$item->vehicle_number}: {$item->part_name} ({$item->sku}) — {$item->quantity} remaining (min: {$item->min_quantity})\n";
}
$mailer = Factory::getMailer();
$mailer->addRecipient(Factory::getApplication()->get('mailfrom'));
$mailer->setSubject('Field: ' . count($low) . ' truck stock items need reorder');
$mailer->setBody($body);
$mailer->Send();
}
Log::add("Field truck stock: " . count($low) . " items low", Log::INFO, 'mokosuite.field');
return Status::OK;
}
}
@@ -1,27 +0,0 @@
<?php
namespace Moko\Plugin\WebServices\MokoSuiteField\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Event\Application\BeforeApiRouteEvent;
use Joomla\Event\SubscriberInterface;
final class MokoSuiteFieldApi extends CMSPlugin implements SubscriberInterface
{
public static function getSubscribedEvents(): array
{
return ['onBeforeApiRoute' => 'onBeforeApiRoute'];
}
public function onBeforeApiRoute(BeforeApiRouteEvent $event): void
{
$router = $event->getRouter();
$router->createCRUDRoutes('v1/mokosuite/field/workorders', 'fieldworkorders', ['component' => 'com_mokosuitefield']);
$router->createCRUDRoutes('v1/mokosuite/field/technicians', 'fieldtechnicians', ['component' => 'com_mokosuitefield']);
$router->createCRUDRoutes('v1/mokosuite/field/equipment', 'fieldequipment', ['component' => 'com_mokosuitefield']);
$router->createCRUDRoutes('v1/mokosuite/field/agreements', 'fieldagreements', ['component' => 'com_mokosuitefield']);
$router->createCRUDRoutes('v1/mokosuite/field/estimates', 'fieldestimates', ['component' => 'com_mokosuitefield']);
$router->createCRUDRoutes('v1/mokosuite/field/locations', 'fieldlocations', ['component' => 'com_mokosuitefield']);
}
}
+25 -21
View File
@@ -1,24 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
Authored-by: Moko Consulting
-->
<extension type="package" method="upgrade">
<name>Package - MokoSuite Field</name>
<packagename>mokosuitefield</packagename>
<version>01.08.11</version>
<creationDate>2026-06-12</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license>
<description>MokoSuite Field Service - dispatch, work orders, scheduling, mobile tech. Layer 2 add-on for MokoSuite (requires CRM).</description>
<php_minimum>8.3</php_minimum>
<dlid prefix="dlid=" suffix=""/>
<blockChildUninstall>true</blockChildUninstall>
<files folder="packages">
<file type="plugin" id="plg_system_mokosuitefield" group="system">plg_system_mokosuitefield.zip</file>
<file type="component" id="com_mokosuitefield">com_mokosuitefield.zip</file>
<file type="plugin" id="plg_webservices_mokosuitefield" group="webservices">plg_webservices_mokosuitefield.zip</file>
</files>
<updateservers>
<server type="extension" priority="1" name="Package - MokoSuite Field">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteField/updates.xml</server>
</updateservers>
<name>pkg_mokosuitefield</name>
<packagename>mokosuitefield</packagename>
<version>0.1.0</version>
<creationDate>2026-06-27</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting</copyright>
<license>GPL-3.0-or-later</license>
<description>PKG_MOKOSUITEFIELD_DESCRIPTION</description>
<files>
<file type="component" id="com_mokosuitefield">components/com_mokosuitefield</file>
<file type="plugin" id="plg_system_mokosuitefield" group="system">plugins/system/mokosuitefield</file>
<file type="plugin" id="plg_webservices_mokosuitefield" group="webservices">plugins/webservices/mokosuitefield</file>
</files>
<updateservers>
<server type="extension" name="MokoSuiteField Updates">https://git.mokoconsulting.tech/api/packages/MokoConsulting/generic/pkg_mokosuitefield/latest/updates.xml</server>
</updateservers>
</extension>
@@ -0,0 +1,13 @@
; Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
; SPDX-License-Identifier: GPL-3.0-or-later
; Authored-by: Moko Consulting
PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field"
PLG_SYSTEM_MOKOSUITEFIELD_DESCRIPTION="Field service management system plugin for MokoSuite."
PLG_SYSTEM_MOKOSUITEFIELD_PARAM_COMPANY_NAME="Company Name"
PLG_SYSTEM_MOKOSUITEFIELD_PARAM_SERVICE_RADIUS="Service Radius (km)"
PLG_SYSTEM_MOKOSUITEFIELD_PARAM_AUTO_DISPATCH="Auto-dispatch"
PLG_SYSTEM_MOKOSUITEFIELD_PARAM_TIMEOUT_MINUTES="Dispatch Timeout (minutes)"
PLG_SYSTEM_MOKOSUITEFIELD_PARAM_DEFAULT_HOURLY_RATE="Default Hourly Rate"
PLG_SYSTEM_MOKOSUITEFIELD_PARAM_TAX_RATE="Tax Rate (%)"
PLG_SYSTEM_MOKOSUITEFIELD_PARAM_LOW_STOCK_THRESHOLD="Low Stock Threshold"
@@ -0,0 +1,6 @@
; Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
; SPDX-License-Identifier: GPL-3.0-or-later
; Authored-by: Moko Consulting
PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field"
PLG_SYSTEM_MOKOSUITEFIELD_DESCRIPTION="Field service management system plugin for MokoSuite."
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
Authored-by: Moko Consulting
-->
<extension type="plugin" group="system" method="upgrade">
<name>plg_system_mokosuitefield</name>
<version>0.1.0</version>
<creationDate>2026-06-27</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_SYSTEM_MOKOSUITEFIELD_DESCRIPTION</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteField</namespace>
<files>
<folder>src</folder>
<folder>services</folder>
<folder>sql</folder>
<folder>language</folder>
</files>
<install>
<sql><file driver="mysql" charset="utf8">sql/install.sql</file></sql>
</install>
<uninstall>
<sql><file driver="mysql" charset="utf8">sql/uninstall.sql</file></sql>
</uninstall>
<languages folder="language">
<language tag="en-GB">en-GB/plg_system_mokosuitefield.ini</language>
<language tag="en-GB">en-GB/plg_system_mokosuitefield.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic">
<field
name="company_name"
type="text"
label="PLG_SYSTEM_MOKOSUITEFIELD_PARAM_COMPANY_NAME"
default=""
/>
<field
name="service_radius"
type="number"
label="PLG_SYSTEM_MOKOSUITEFIELD_PARAM_SERVICE_RADIUS"
default="50"
min="1"
step="1"
/>
</fieldset>
<fieldset name="dispatch">
<field
name="auto_dispatch"
type="list"
label="PLG_SYSTEM_MOKOSUITEFIELD_PARAM_AUTO_DISPATCH"
default="0"
>
<option value="0">JNO</option>
<option value="1">JYES</option>
</field>
<field
name="timeout_minutes"
type="number"
label="PLG_SYSTEM_MOKOSUITEFIELD_PARAM_TIMEOUT_MINUTES"
default="30"
min="1"
step="1"
/>
</fieldset>
<fieldset name="billing">
<field
name="default_hourly_rate"
type="number"
label="PLG_SYSTEM_MOKOSUITEFIELD_PARAM_DEFAULT_HOURLY_RATE"
default="75.00"
min="0"
step="0.01"
/>
<field
name="tax_rate"
type="number"
label="PLG_SYSTEM_MOKOSUITEFIELD_PARAM_TAX_RATE"
default="0.00"
min="0"
step="0.01"
/>
</fieldset>
<fieldset name="parts">
<field
name="low_stock_threshold"
type="number"
label="PLG_SYSTEM_MOKOSUITEFIELD_PARAM_LOW_STOCK_THRESHOLD"
default="5"
min="0"
step="1"
/>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,36 @@
<?php
/**
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GPL-3.0-or-later
* @author Moko Consulting
*/
\defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\System\MokoSuiteField\Extension\MokoSuiteField;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new MokoSuiteField(
$dispatcher,
(array) PluginHelper::getPlugin('system', 'mokosuitefield')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,170 @@
-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
-- SPDX-License-Identifier: GPL-3.0-or-later
-- Authored-by: Moko Consulting
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_technicians` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`contact_id` INT DEFAULT NULL,
`name` VARCHAR(255) NOT NULL,
`email` VARCHAR(255) NOT NULL DEFAULT '',
`phone` VARCHAR(50) NOT NULL DEFAULT '',
`skills` VARCHAR(500) NOT NULL DEFAULT '',
`hourly_rate` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`status` ENUM('active','inactive','on_leave','training') NOT NULL DEFAULT 'active',
`current_lat` DECIMAL(10,7) DEFAULT NULL,
`current_lng` DECIMAL(10,7) DEFAULT NULL,
`published` TINYINT NOT NULL DEFAULT 1,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_contact` (`contact_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_equipment` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`customer_contact_id` INT DEFAULT NULL,
`name` VARCHAR(255) NOT NULL,
`equipment_type` ENUM('hvac','plumbing','electrical','appliance','generator','elevator','fire_system','other') NOT NULL DEFAULT 'other',
`brand` VARCHAR(100) NOT NULL DEFAULT '',
`model` VARCHAR(100) NOT NULL DEFAULT '',
`serial_number` VARCHAR(100) NOT NULL DEFAULT '',
`install_date` DATE DEFAULT NULL,
`warranty_expiry` DATE DEFAULT NULL,
`location_address` VARCHAR(500) NOT NULL DEFAULT '',
`notes` TEXT,
`published` TINYINT NOT NULL DEFAULT 1,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_customer` (`customer_contact_id`),
KEY `idx_type` (`equipment_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_equipment_history` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`equipment_id` INT UNSIGNED NOT NULL,
`work_order_id` INT UNSIGNED DEFAULT NULL,
`action` ENUM('install','repair','maintenance','inspection','replacement','decommission') NOT NULL DEFAULT 'maintenance',
`description` VARCHAR(500) NOT NULL DEFAULT '',
`technician_id` INT UNSIGNED DEFAULT NULL,
`action_date` DATE NOT NULL,
`cost` DECIMAL(10,2) DEFAULT NULL,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_equipment` (`equipment_id`),
KEY `idx_date` (`action_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_work_orders` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`work_order_ref` VARCHAR(20) NOT NULL,
`customer_contact_id` INT DEFAULT NULL,
`customer_name` VARCHAR(255) NOT NULL,
`customer_phone` VARCHAR(50) NOT NULL DEFAULT '',
`equipment_id` INT UNSIGNED DEFAULT NULL,
`technician_id` INT UNSIGNED DEFAULT NULL,
`status` ENUM('requested','scheduled','dispatched','in_progress','on_hold','completed','invoiced','cancelled') NOT NULL DEFAULT 'requested',
`priority` ENUM('emergency','high','normal','low') NOT NULL DEFAULT 'normal',
`work_type` ENUM('repair','maintenance','installation','inspection','warranty','callback') NOT NULL DEFAULT 'repair',
`title` VARCHAR(255) NOT NULL,
`description` TEXT,
`site_address` VARCHAR(500) NOT NULL,
`site_lat` DECIMAL(10,7) DEFAULT NULL,
`site_lng` DECIMAL(10,7) DEFAULT NULL,
`scheduled_date` DATE DEFAULT NULL,
`dispatched_at` DATETIME DEFAULT NULL,
`completed_at` DATETIME DEFAULT NULL,
`labor_hours` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
`labor_cost` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`parts_cost` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`total_cost` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`customer_signature` TEXT,
`notes` TEXT,
`created` DATETIME NOT NULL,
`created_by` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_ref` (`work_order_ref`),
KEY `idx_customer` (`customer_contact_id`),
KEY `idx_technician` (`technician_id`),
KEY `idx_status` (`status`),
KEY `idx_scheduled` (`scheduled_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_parts` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
`part_number` VARCHAR(100) NOT NULL DEFAULT '',
`category` VARCHAR(100) NOT NULL DEFAULT '',
`unit_cost` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`sell_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`stock_qty` INT NOT NULL DEFAULT 0,
`reorder_level` INT UNSIGNED NOT NULL DEFAULT 5,
`supplier` VARCHAR(255) NOT NULL DEFAULT '',
`published` TINYINT NOT NULL DEFAULT 1,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_part_number` (`part_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_truck_inventory` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`technician_id` INT UNSIGNED NOT NULL,
`part_id` INT UNSIGNED NOT NULL,
`quantity` INT NOT NULL DEFAULT 0,
`last_restocked` DATE DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_tech_part` (`technician_id`, `part_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_checklists` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
`work_type` ENUM('repair','maintenance','installation','inspection','warranty','callback','all') NOT NULL DEFAULT 'all',
`published` TINYINT NOT NULL DEFAULT 1,
`ordering` INT NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_checklist_items` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`checklist_id` INT UNSIGNED NOT NULL,
`label` VARCHAR(255) NOT NULL,
`item_type` ENUM('checkbox','text','number','photo','pass_fail') NOT NULL DEFAULT 'checkbox',
`required` TINYINT NOT NULL DEFAULT 0,
`ordering` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_checklist` (`checklist_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_pm_agreements` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`customer_contact_id` INT NOT NULL,
`equipment_id` INT UNSIGNED DEFAULT NULL,
`name` VARCHAR(255) NOT NULL,
`frequency` ENUM('monthly','quarterly','semi_annual','annual') NOT NULL DEFAULT 'annual',
`next_service_date` DATE DEFAULT NULL,
`annual_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`status` ENUM('active','expired','cancelled') NOT NULL DEFAULT 'active',
`start_date` DATE NOT NULL,
`end_date` DATE DEFAULT NULL,
`auto_renew` TINYINT NOT NULL DEFAULT 1,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_customer` (`customer_contact_id`),
KEY `idx_next_service` (`next_service_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_dispatches` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`work_order_id` INT UNSIGNED NOT NULL,
`technician_id` INT UNSIGNED NOT NULL,
`status` ENUM('offered','accepted','rejected','expired','cancelled') NOT NULL DEFAULT 'offered',
`offered_at` DATETIME NOT NULL,
`responded_at` DATETIME DEFAULT NULL,
`distance_km` DECIMAL(10,2) DEFAULT NULL,
`eta_minutes` DECIMAL(10,2) DEFAULT NULL,
`attempt_number` TINYINT UNSIGNED NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
KEY `idx_work_order` (`work_order_id`),
KEY `idx_technician` (`technician_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,14 @@
-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
-- SPDX-License-Identifier: GPL-3.0-or-later
-- Authored-by: Moko Consulting
DROP TABLE IF EXISTS `#__mokosuitefield_dispatches`;
DROP TABLE IF EXISTS `#__mokosuitefield_pm_agreements`;
DROP TABLE IF EXISTS `#__mokosuitefield_checklist_items`;
DROP TABLE IF EXISTS `#__mokosuitefield_checklists`;
DROP TABLE IF EXISTS `#__mokosuitefield_truck_inventory`;
DROP TABLE IF EXISTS `#__mokosuitefield_parts`;
DROP TABLE IF EXISTS `#__mokosuitefield_work_orders`;
DROP TABLE IF EXISTS `#__mokosuitefield_equipment_history`;
DROP TABLE IF EXISTS `#__mokosuitefield_equipment`;
DROP TABLE IF EXISTS `#__mokosuitefield_technicians`;

Some files were not shown because too many files have changed in this diff Show More