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