14 Commits

Author SHA1 Message Date
Jonathan Miller d2dc55539f Update MokoSuite → MokoSuiteClient submodule reference
Updated .gitmodules URL and path for the MokoSuite → MokoSuiteClient rename.
2026-06-15 16:49:11 -05:00
Jonathan Miller 3ba79d4367 Add 7 admin models — WorkOrders, Technicians, Equipment, Vehicles, ServiceAgreements, Estimates, Dispatch
All use Joomla 6 BaseDatabaseModel with $this->getDatabase()
2026-06-15 00:50:26 -05:00
Jonathan Miller 5b923b4869 Add FieldEquipment and FieldEstimates API controllers
- FieldEquipmentController: equipment CRUD, vehicles, truck stock, service agreements
- FieldEstimatesController: estimates CRUD, convert-to-WO, route optimization API
2026-06-15 00:35:18 -05:00
Jonathan Miller 532e514106 Add RouteHelper — daily route building, nearest-neighbor optimization, GPS breadcrumbs, drive time estimates 2026-06-14 15:00:47 -05:00
Jonathan Miller d08648f2fc feat: customer service portal + mobile API (GPS, photos, time, parts) 2026-06-13 09:20:13 -05:00
Jonathan Miller ca513b61ee feat: mobile tech API — GPS, photos, time tracking, parts usage, equipment QR
FieldMobileController: 8 endpoints for field tech mobile app — my jobs
(priority-sorted with customer/location data), status updates with GPS,
photo upload with geotag, start/stop time entries, log parts from truck
stock (auto-deducts + adds WO line item), GPS heartbeat, equipment QR
code lookup. Auth requires technician profile linked to Joomla user.
2026-06-13 08:57:09 -05:00
Jonathan Miller a146c106a3 chore: CI workflow, language files, CSS, JS infrastructure 2026-06-13 08:37:43 -05:00
Jonathan Miller ceb03dba48 feat: equipment, dispatch, vehicles views + truck stock/vehicle helpers
TruckStockHelper: per-vehicle inventory, low stock, use/restock parts.
VehicleHelper: fleet overview, inspection due dates.
Admin views: Equipment list with service due alerts, Dispatch board
with date picker and tech assignments, Vehicles fleet with low stock
indicators and inspection tracking. All with templates.
2026-06-13 08:14:33 -05:00
Jonathan Miller 00b1cacceb feat: technician mobile view + public service booking page
TechMobile: tablet/phone view for field techs — today's jobs sorted by
priority, current job card with address/access notes/phone link, GPS
status updates (en route/arrived/start/complete), Google Maps navigation.
BookService: public form for customers to request service — trade
selector, priority, description, auto contact + work order creation.
2026-06-13 08:01:56 -05:00
Jonathan Miller 96c42ccf6d feat: wire config.xml params + ACL permission checks
WorkOrderHelper: reads wo_prefix and default_trade from config.xml.
FieldWorkOrderController: requireAuth() checks on list (core.manage),
create (core.create), dispatch (field.dispatch) endpoints.
2026-06-13 07:33:07 -05:00
Jonathan Miller e9f0bf85a4 chore: comprehensive config.xml settings + access.xml permissions 2026-06-13 07:20:14 -05:00
Jonathan Miller b39abf8bd5 feat: webservices plugin, task scheduler, router, config, access
FieldAutomation task plugin: service reminders, agreement renewal alerts,
equipment warranty expiry, truck stock reorder.
MokoSuiteFieldApi webservices: 6 CRUD routes (workorders, technicians,
equipment, agreements, estimates, locations). Router, config.xml, access.xml.
2026-06-13 07:10:18 -05:00
Jonathan Miller a03b584b67 feat: service agreements, equipment, estimates, work orders list, technicians, API
ServiceAgreementHelper: active agreements, expiring, revenue summary.
EquipmentHelper: location equipment, QR lookup, service due, warranty expiry.
EstimateHelper: create, send, accept with signature, auto-convert to WO.
FieldWorkOrderController API: CRUD + dispatch + board + available techs.
Admin views: WorkOrders list, Technicians list, ServiceAgreements with
recurring revenue dashboard. All with templates.
2026-06-13 06:53:05 -05:00
Jonathan Miller b05234ef1a feat: initial scaffold — MokoSuiteField service management
Layer 2 add-on for MokoSuite CRM. Field service operations for
plumbing, electrical, HVAC, and general trades.

Schema (12 tables):
- Technicians (trade, certifications, GPS, service radius, vehicle)
- Service Locations (customer properties with access notes, GPS)
- Work Orders (full lifecycle: new > dispatched > en_route > on_site >
  in_progress > completed > invoiced, with priority/trade/category)
- WO Line Items (labor, parts, materials, flat rate, permits)
- WO Photos (before/during/after with GPS coordinates)
- Service Agreements (recurring maintenance contracts with SLA)
- Equipment (HVAC units, panels, water heaters with serial/warranty)
- Vehicles (fleet tracking with mileage, inspection, GPS)
- Truck Stock (per-vehicle parts inventory with reorder points)
- Dispatch Log (assignment and routing history with GPS)
- Estimates (on-site quoting with token-based customer acceptance)
- Time Entries (per-WO labor tracking with overtime/travel flags)

Helpers:
- DispatchHelper: find best tech by trade/location/workload, dispatch
  board, auto-assignment, unassigned queue
- WorkOrderHelper: create, status lifecycle, completion with signature,
  dashboard stats

Infrastructure:
- Joomla 6 (PHP 8.3+), admin dashboard with dispatch board
- Git submodules: MokoSuite + MokoSuiteCRM
- GPL-3.0 license
2026-06-13 06:23:59 -05:00
61 changed files with 3245 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Build Package
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Build package ZIP
run: |
cd source
# Create individual package ZIPs
for pkg_dir in packages/*/; do
pkg_name=$(basename "$pkg_dir")
cd "$pkg_dir"
zip -r "../../${pkg_name}.zip" . -x "*.git*"
cd ../..
done
# Create main package ZIP with all sub-packages + manifest
zip -j "pkg_mokosuitefield.zip" pkg_*.xml script.php updates.xml *.zip 2>/dev/null || true
ls -la *.zip
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: source/pkg_mokosuitefield.zip
generate_release_notes: true
+6
View File
@@ -0,0 +1,6 @@
[submodule "packages/MokoSuiteClient"]
path = packages/MokoSuiteClient
url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient.git
[submodule "packages/MokoSuiteCRM"]
path = packages/MokoSuiteCRM
url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM.git
+12
View File
@@ -0,0 +1,12 @@
# Changelog
## [01.01.00] - 2026-06-12
### 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+)
+1
View File
@@ -0,0 +1 @@
GPL-3.0-or-later
@@ -0,0 +1,11 @@
<?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>
@@ -0,0 +1,25 @@
<?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>
@@ -0,0 +1,18 @@
<?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;
});
}
};
@@ -0,0 +1,11 @@
<?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';
}
@@ -0,0 +1,37 @@
<?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() ?: [];
}
}
@@ -0,0 +1,29 @@
<?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() ?: [];
}
}
@@ -0,0 +1,28 @@
<?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() ?: [];
}
}
@@ -0,0 +1,39 @@
<?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() ?: [];
}
}
@@ -0,0 +1,27 @@
<?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() ?: [];
}
}
@@ -0,0 +1,26 @@
<?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() ?: [];
}
}
@@ -0,0 +1,61 @@
<?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, loc.*, 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')
->where('wo.id = ' . $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);
}
}
@@ -0,0 +1,40 @@
<?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);
}
}
@@ -0,0 +1,28 @@
<?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);
}
}
@@ -0,0 +1,34 @@
<?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);
}
}
@@ -0,0 +1,23 @@
<?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);
}
}
@@ -0,0 +1,32 @@
<?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);
}
}
@@ -0,0 +1,23 @@
<?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);
}
}
@@ -0,0 +1,52 @@
<?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);
}
}
@@ -0,0 +1,33 @@
<?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>
@@ -0,0 +1,10 @@
<?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; ?>
@@ -0,0 +1,10 @@
<?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>
@@ -0,0 +1,8 @@
<?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>
@@ -0,0 +1,7 @@
<?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>
@@ -0,0 +1,9 @@
<?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>
@@ -0,0 +1,34 @@
<?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>
@@ -0,0 +1,178 @@
<?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();
}
}
@@ -0,0 +1,150 @@
<?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();
}
}
@@ -0,0 +1,252 @@
<?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();
}
}
@@ -0,0 +1,131 @@
<?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();
}
}
@@ -0,0 +1,9 @@
/* 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; } }
@@ -0,0 +1,6 @@
document.addEventListener('DOMContentLoaded', function() {
// Auto-refresh dispatch board every 30 seconds
if (document.querySelector('.dispatch-board')) {
setInterval(function() { location.reload(); }, 30000);
}
});
@@ -0,0 +1,8 @@
<?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';
}
@@ -0,0 +1,21 @@
<?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;
}
}
@@ -0,0 +1,78 @@
<?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);
}
}
@@ -0,0 +1,89 @@
<?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);
}
}
@@ -0,0 +1,70 @@
<?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);
}
}
@@ -0,0 +1,33 @@
<?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>
@@ -0,0 +1,14 @@
<?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>
@@ -0,0 +1,56 @@
<?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>
@@ -0,0 +1,2 @@
PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field"
PLG_SYSTEM_MOKOSUITEFIELD_DESC="MokoSuite Field Service system plugin - database schema and helpers."
@@ -0,0 +1,2 @@
PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field"
PLG_SYSTEM_MOKOSUITEFIELD_DESC="MokoSuite Field Service system plugin."
@@ -0,0 +1,332 @@
--
-- 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;
@@ -0,0 +1,12 @@
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`;
@@ -0,0 +1,144 @@
<?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() ?: [];
}
}
@@ -0,0 +1,77 @@
<?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');
}
}
@@ -0,0 +1,83 @@
<?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;
}
}
@@ -0,0 +1,237 @@
<?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
{
/**
* 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 / 0.5, 0), // ~30 mph avg in service areas
];
$prevLat = $lat ?: $prevLat;
$prevLng = $lng ?: $prevLng;
}
$totalDriveMin = $totalDistance > 0 ? round($totalDistance / 0.5) : 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);
}
}
@@ -0,0 +1,78 @@
<?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();
}
}
@@ -0,0 +1,64 @@
<?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 = ' . $vehicleId)
->where('product_id = ' . $productId));
$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 ({$vehicleId}, {$productId}, {$qty}, CURDATE()) ON DUPLICATE KEY UPDATE quantity = quantity + {$qty}, last_restocked = CURDATE()");
$db->execute();
}
}
@@ -0,0 +1,44 @@
<?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() ?: [];
}
}
@@ -0,0 +1,162 @@
<?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];
}
}
@@ -0,0 +1,144 @@
<?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;
}
}
@@ -0,0 +1,27 @@
<?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']);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="package" method="upgrade">
<name>Package - MokoSuite Field</name>
<packagename>mokosuitefield</packagename>
<version>01.01.00</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>
</extension>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<updates><update>
<name>Package - MokoSuite Field</name>
<element>pkg_mokosuitefield</element>
<type>package</type>
<version>01.01.00</version>
<targetplatform name="joomla" version="6.[0-9]" />
<php_minimum>8.3</php_minimum>
</update></updates>