Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2dc55539f | |||
| 3ba79d4367 | |||
| 5b923b4869 | |||
| 532e514106 | |||
| d08648f2fc | |||
| ca513b61ee | |||
| a146c106a3 | |||
| ceb03dba48 | |||
| 00b1cacceb | |||
| 96c42ccf6d | |||
| e9f0bf85a4 | |||
| b39abf8bd5 | |||
| a03b584b67 | |||
| b05234ef1a |
@@ -0,0 +1,33 @@
|
||||
name: Build Package
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Build package ZIP
|
||||
run: |
|
||||
cd source
|
||||
# Create individual package ZIPs
|
||||
for pkg_dir in packages/*/; do
|
||||
pkg_name=$(basename "$pkg_dir")
|
||||
cd "$pkg_dir"
|
||||
zip -r "../../${pkg_name}.zip" . -x "*.git*"
|
||||
cd ../..
|
||||
done
|
||||
# Create main package ZIP with all sub-packages + manifest
|
||||
zip -j "pkg_mokosuitefield.zip" pkg_*.xml script.php updates.xml *.zip 2>/dev/null || true
|
||||
ls -la *.zip
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: source/pkg_mokosuitefield.zip
|
||||
generate_release_notes: true
|
||||
@@ -0,0 +1,6 @@
|
||||
[submodule "packages/MokoSuiteClient"]
|
||||
path = packages/MokoSuiteClient
|
||||
url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient.git
|
||||
[submodule "packages/MokoSuiteCRM"]
|
||||
path = packages/MokoSuiteCRM
|
||||
url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM.git
|
||||
@@ -0,0 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [01.01.00] - 2026-06-12
|
||||
### Added
|
||||
- Initial scaffold: field service management for MokoSuite
|
||||
- 12 database tables for technicians, work orders, service agreements, equipment, vehicles, estimates
|
||||
- 7 helpers: Dispatch, WorkOrder, ServiceAgreement, Equipment, Estimate, TruckStock, Vehicle
|
||||
- Admin views: Dashboard, Work Orders, Technicians, Service Agreements, Equipment, Dispatch, Vehicles
|
||||
- Site views: Tech Mobile (tablet), Book Service (public form)
|
||||
- API controller with 6 endpoints
|
||||
- Task scheduler: service reminders, agreement renewals, equipment warranty, truck stock reorder
|
||||
- Joomla 6 architecture (PHP 8.3+)
|
||||
Submodule
+1
Submodule packages/MokoSuiteCRM added at 6c78af16e6
Submodule
+1
Submodule packages/MokoSuiteClient added at 6cd16d9845
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<access component="com_mokosuitefield">
|
||||
<section name="component">
|
||||
<action name="core.admin" title="JACTION_ADMIN" />
|
||||
<action name="core.manage" title="JACTION_MANAGE" />
|
||||
<action name="core.create" title="JACTION_CREATE" />
|
||||
<action name="core.edit" title="JACTION_EDIT" />
|
||||
<action name="field.dispatch" title="Dispatch Work Orders" />
|
||||
<action name="field.estimates" title="Create Estimates" />
|
||||
</section>
|
||||
</access>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<config>
|
||||
<fieldset name="basic" label="Field Service Settings">
|
||||
<field name="company_name" type="text" default="" label="Company Name" />
|
||||
<field name="default_trade" type="list" default="general" label="Default Trade">
|
||||
<option value="general">General</option>
|
||||
<option value="plumbing">Plumbing</option>
|
||||
<option value="electrical">Electrical</option>
|
||||
<option value="hvac">HVAC</option>
|
||||
</field>
|
||||
<field name="wo_prefix" type="text" default="WO" label="Work Order Prefix" />
|
||||
</fieldset>
|
||||
<fieldset name="dispatch" label="Dispatch">
|
||||
<field name="auto_dispatch" type="radio" default="0" label="Auto-Dispatch" class="btn-group btn-group-yesno"><option value="1">JYES</option><option value="0">JNO</option></field>
|
||||
<field name="default_service_radius" type="number" default="30" label="Default Service Radius (miles)" />
|
||||
</fieldset>
|
||||
<fieldset name="billing" label="Billing">
|
||||
<field name="default_labor_rate" type="number" default="125" step="0.01" label="Default Labor Rate ($/hr)" />
|
||||
<field name="overtime_multiplier" type="number" default="1.5" step="0.1" label="Overtime Multiplier" />
|
||||
<field name="travel_charge" type="number" default="0" step="0.01" label="Travel Charge ($)" />
|
||||
</fieldset>
|
||||
<fieldset name="permissions" label="Permissions">
|
||||
<field name="rules" type="rules" component="com_mokosuitefield" section="component" />
|
||||
</fieldset>
|
||||
</config>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
|
||||
use Joomla\CMS\Extension\ComponentInterface;
|
||||
use Joomla\CMS\Extension\MVCComponent;
|
||||
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
return new class implements ServiceProviderInterface {
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->set(ComponentInterface::class, function (Container $container) {
|
||||
$component = new MVCComponent($container->get(ComponentDispatcherFactoryInterface::class));
|
||||
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
|
||||
return $component;
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
|
||||
class DisplayController extends BaseController
|
||||
{
|
||||
protected $default_view = 'dashboard';
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class DispatchModel extends BaseDatabaseModel
|
||||
{
|
||||
public function getTodayBoard(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('t.id AS tech_id, cd.name AS tech_name, t.trade, t.status AS tech_status')
|
||||
->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.scheduled_date = CURDATE() AND wo.status NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ')) AS pending_jobs')
|
||||
->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.scheduled_date = CURDATE() AND wo.status = ' . $db->quote('completed') . ') AS completed_jobs')
|
||||
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->where($db->quoteName('t.status') . ' != ' . $db->quote('inactive'))
|
||||
->order('cd.name ASC'));
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
public function getGpsLog(int $techId, string $date = ''): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$date = $date ?: date('Y-m-d');
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('*')
|
||||
->from('#__mokosuitefield_dispatch_log')
|
||||
->where('technician_id = ' . $techId)
|
||||
->where('DATE(recorded_at) = ' . $db->quote($date))
|
||||
->order('recorded_at ASC'));
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class EquipmentModel extends BaseDatabaseModel
|
||||
{
|
||||
public function getItems(string $type = '', string $status = '', int $locationId = 0, string $search = '', int $limit = 50): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select('eq.*, loc.name AS location_name, loc.address, cd.name AS customer_name')
|
||||
->from($db->quoteName('#__mokosuitefield_equipment', 'eq'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = eq.location_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = eq.contact_id')
|
||||
->order('eq.name ASC');
|
||||
|
||||
if ($type) $query->where($db->quoteName('eq.type') . ' = ' . $db->quote($type));
|
||||
if ($status) $query->where($db->quoteName('eq.status') . ' = ' . $db->quote($status));
|
||||
if ($locationId) $query->where('eq.location_id = ' . $locationId);
|
||||
if ($search) $query->where('(' . $db->quoteName('eq.name') . ' LIKE ' . $db->quote('%' . $search . '%')
|
||||
. ' OR ' . $db->quoteName('eq.serial_number') . ' LIKE ' . $db->quote('%' . $search . '%') . ')');
|
||||
|
||||
$db->setQuery($query, 0, $limit);
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class EstimatesModel extends BaseDatabaseModel
|
||||
{
|
||||
public function getItems(string $status = '', string $search = '', int $techId = 0, int $limit = 50): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select('e.*, cd.name AS customer_name, loc.address')
|
||||
->from($db->quoteName('#__mokosuitefield_estimates', 'e'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id')
|
||||
->order('e.created DESC');
|
||||
|
||||
if ($status) $query->where($db->quoteName('e.status') . ' = ' . $db->quote($status));
|
||||
if ($techId) $query->where('e.technician_id = ' . $techId);
|
||||
if ($search) $query->where('(' . $db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%')
|
||||
. ' OR ' . $db->quoteName('e.estimate_number') . ' LIKE ' . $db->quote('%' . $search . '%') . ')');
|
||||
|
||||
$db->setQuery($query, 0, $limit);
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class ServiceAgreementsModel extends BaseDatabaseModel
|
||||
{
|
||||
public function getItems(string $status = '', string $search = '', int $limit = 50): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select('sa.*, cd.name AS customer_name, loc.address')
|
||||
->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = sa.location_id')
|
||||
->order('sa.end_date ASC');
|
||||
|
||||
if ($status) $query->where($db->quoteName('sa.status') . ' = ' . $db->quote($status));
|
||||
if ($search) $query->where($db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%'));
|
||||
|
||||
$db->setQuery($query, 0, $limit);
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
public function getExpiringSoon(int $days = 30): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('sa.*, cd.name AS customer_name')
|
||||
->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id')
|
||||
->where($db->quoteName('sa.status') . ' = ' . $db->quote('active'))
|
||||
->where($db->quoteName('sa.end_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $days . ' DAY)')
|
||||
->order('sa.end_date ASC'));
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class TechniciansModel extends BaseDatabaseModel
|
||||
{
|
||||
public function getItems(string $status = '', string $trade = '', string $search = '', int $limit = 50): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select('t.*, cd.name AS tech_name, cd.email_to, cd.telephone')
|
||||
->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.status IN (' . $db->quote('dispatched') . ',' . $db->quote('in_progress') . ')) AS active_jobs')
|
||||
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->order('cd.name ASC');
|
||||
|
||||
if ($status) $query->where($db->quoteName('t.status') . ' = ' . $db->quote($status));
|
||||
if ($trade) $query->where($db->quoteName('t.trade') . ' = ' . $db->quote($trade));
|
||||
if ($search) $query->where($db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%'));
|
||||
|
||||
$db->setQuery($query, 0, $limit);
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class VehiclesModel extends BaseDatabaseModel
|
||||
{
|
||||
public function getItems(string $status = '', int $techId = 0, int $limit = 50): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select('v.*, cd.name AS tech_name')
|
||||
->from($db->quoteName('#__mokosuitefield_vehicles', 'v'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.technician_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->order('v.vehicle_name ASC');
|
||||
|
||||
if ($status) $query->where($db->quoteName('v.status') . ' = ' . $db->quote($status));
|
||||
if ($techId) $query->where('v.technician_id = ' . $techId);
|
||||
|
||||
$db->setQuery($query, 0, $limit);
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class WorkOrdersModel extends BaseDatabaseModel
|
||||
{
|
||||
public function getItems(string $status = '', string $trade = '', int $techId = 0, string $search = '', string $date = '', int $limit = 50, int $offset = 0): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select('wo.*, cd.name AS customer_name, loc.address, loc.city, loc.state, loc.zip')
|
||||
->select('t_cd.name AS tech_name')
|
||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id')
|
||||
->order('wo.scheduled_date DESC, wo.priority DESC');
|
||||
|
||||
if ($status) $query->where($db->quoteName('wo.status') . ' = ' . $db->quote($status));
|
||||
if ($trade) $query->where($db->quoteName('wo.trade') . ' = ' . $db->quote($trade));
|
||||
if ($techId) $query->where('wo.technician_id = ' . $techId);
|
||||
if ($date) $query->where('wo.scheduled_date = ' . $db->quote($date));
|
||||
if ($search) {
|
||||
$query->where('(' . $db->quoteName('wo.wo_number') . ' LIKE ' . $db->quote('%' . $search . '%')
|
||||
. ' OR ' . $db->quoteName('wo.description') . ' LIKE ' . $db->quote('%' . $search . '%')
|
||||
. ' OR ' . $db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%') . ')');
|
||||
}
|
||||
|
||||
$db->setQuery($query, $offset, $limit);
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
public function getWorkOrder(int $id): ?object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('wo.*, cd.name AS customer_name, loc.*, t_cd.name AS tech_name')
|
||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id')
|
||||
->where('wo.id = ' . $id));
|
||||
return $db->loadObject();
|
||||
}
|
||||
|
||||
public function getStatusCounts(): object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('status, COUNT(*) AS cnt')
|
||||
->from('#__mokosuitefield_work_orders')
|
||||
->group('status'));
|
||||
$rows = $db->loadObjectList('status') ?: [];
|
||||
return (object) array_map(fn($r) => (int) $r->cnt, (array) $rows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\View\Dashboard;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
public object $stats;
|
||||
public array $dispatchBoard = [];
|
||||
public array $unassigned = [];
|
||||
public array $urgent = [];
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->stats = \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::getDashboardStats();
|
||||
$this->dispatchBoard = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getDispatchBoard();
|
||||
$this->unassigned = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getUnassigned();
|
||||
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
// Urgent/emergency jobs
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('wo.*, cd.name AS customer_name, loc.address')
|
||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
|
||||
->where($db->quoteName('wo.priority') . ' IN (' . $db->quote('emergency') . ',' . $db->quote('urgent') . ')')
|
||||
->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ',' . $db->quote('invoiced') . ')')
|
||||
->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ') ASC, wo.created ASC'), 0, 10);
|
||||
$this->urgent = $db->loadObjectList() ?: [];
|
||||
|
||||
ToolbarHelper::title('MokoSuite Field Service', 'icon-wrench');
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\View\Dispatch;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
public array $board = [];
|
||||
public array $unassigned = [];
|
||||
public object $stats;
|
||||
public string $date;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d'));
|
||||
|
||||
$this->board = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getDispatchBoard($this->date);
|
||||
$this->unassigned = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getUnassigned();
|
||||
$this->stats = \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::getDashboardStats();
|
||||
|
||||
ToolbarHelper::title('Field Service - Dispatch Board', 'icon-map');
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\View\Equipment;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
public array $equipment = [];
|
||||
public array $serviceDue = [];
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('e.*, loc.address, loc.city, cd.name AS owner_name')
|
||||
->from($db->quoteName('#__mokosuitefield_equipment', 'e'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
|
||||
->order('e.equipment_type ASC, e.make ASC'));
|
||||
$this->equipment = $db->loadObjectList() ?: [];
|
||||
|
||||
$this->serviceDue = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getDueForService(30);
|
||||
|
||||
ToolbarHelper::title('Field Service — Equipment', 'icon-cogs');
|
||||
ToolbarHelper::addNew('equipment.add');
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\View\ServiceAgreements;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
public array $agreements = [];
|
||||
public object $revenue;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->agreements = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getActiveAgreements();
|
||||
$this->revenue = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getRevenueSummary();
|
||||
|
||||
ToolbarHelper::title('Field Service — Service Agreements', 'icon-file-contract');
|
||||
ToolbarHelper::addNew('serviceagreements.add');
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\View\Technicians;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
public array $technicians = [];
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('t.*, cd.name AS tech_name, cd.telephone, cd.email_to, v.vehicle_number')
|
||||
->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.status = ' . $db->quote('completed') . ' AND MONTH(wo.actual_departure) = MONTH(NOW())) AS jobs_this_month')
|
||||
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_vehicles', 'v') . ' ON v.id = t.vehicle_id')
|
||||
->order('cd.name ASC'));
|
||||
$this->technicians = $db->loadObjectList() ?: [];
|
||||
|
||||
ToolbarHelper::title('Field Service — Technicians', 'icon-users');
|
||||
ToolbarHelper::addNew('technicians.add');
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\View\Vehicles;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
public array $vehicles = [];
|
||||
public array $inspectionsDue = [];
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->vehicles = \Moko\Plugin\System\MokoSuiteField\Helper\VehicleHelper::getFleet();
|
||||
$this->inspectionsDue = \Moko\Plugin\System\MokoSuiteField\Helper\VehicleHelper::getInspectionsDue(30);
|
||||
|
||||
ToolbarHelper::title('Field Service — Vehicles', 'icon-truck');
|
||||
ToolbarHelper::addNew('vehicles.add');
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Administrator\View\WorkOrders;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
public array $orders = [];
|
||||
public array $filters = [];
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$this->filters = [
|
||||
'status' => $input->getString('filter_status', ''),
|
||||
'trade' => $input->getString('filter_trade', ''),
|
||||
'date' => $input->getString('filter_date', ''),
|
||||
'search' => $input->getString('filter_search', ''),
|
||||
];
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('wo.*, cd.name AS customer_name, loc.address, loc.city, t_cd.name AS tech_name')
|
||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id')
|
||||
->order('wo.created DESC');
|
||||
|
||||
if ($this->filters['status']) $query->where($db->quoteName('wo.status') . ' = ' . $db->quote($this->filters['status']));
|
||||
if ($this->filters['trade']) $query->where($db->quoteName('wo.trade') . ' = ' . $db->quote($this->filters['trade']));
|
||||
if ($this->filters['date']) $query->where($db->quoteName('wo.scheduled_date') . ' = ' . $db->quote($this->filters['date']));
|
||||
if ($this->filters['search']) {
|
||||
$like = $db->quote('%' . $db->escape($this->filters['search'], true) . '%');
|
||||
$query->where('(wo.wo_number LIKE ' . $like . ' OR cd.name LIKE ' . $like . ')');
|
||||
}
|
||||
|
||||
$db->setQuery($query, 0, 100);
|
||||
$this->orders = $db->loadObjectList() ?: [];
|
||||
|
||||
ToolbarHelper::title('Field Service — Work Orders', 'icon-wrench');
|
||||
ToolbarHelper::addNew('workorders.add');
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
$s = $this->stats;
|
||||
$board = $this->dispatchBoard;
|
||||
$unassigned = $this->unassigned;
|
||||
$urgent = $this->urgent;
|
||||
?>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int) $s->total_today; ?></div><small>Today</small></div></div></div>
|
||||
<div class="col-md-2"><div class="card shadow-sm border-danger"><div class="card-body text-center"><div class="fs-3 fw-bold text-danger"><?php echo (int) $s->urgent; ?></div><small>Urgent</small></div></div></div>
|
||||
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-secondary"><?php echo (int) $s->unassigned; ?></div><small>Unassigned</small></div></div></div>
|
||||
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-warning"><?php echo (int) $s->en_route; ?></div><small>En Route</small></div></div></div>
|
||||
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-primary"><?php echo (int) $s->on_site; ?></div><small>On Site</small></div></div></div>
|
||||
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-success"><?php echo (int) $s->completed; ?></div><small>Done</small></div></div></div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-8"><div class="card shadow-sm"><div class="card-header"><h5 class="mb-0">Dispatch Board</h5></div><div class="card-body">
|
||||
<?php foreach ($board as $tech) : ?>
|
||||
<div class="mb-3 p-2 border rounded">
|
||||
<div class="d-flex justify-content-between mb-1"><strong><?php echo $this->escape($tech->tech_name); ?></strong><span class="badge bg-secondary"><?php echo ucfirst($tech->trade); ?></span></div>
|
||||
<?php if (!empty($tech->jobs)) : foreach ($tech->jobs as $job) : ?>
|
||||
<div class="ms-3 small border-start ps-2 mb-1"><code><?php echo $this->escape($job->wo_number); ?></code> <?php echo $this->escape($job->customer_name ?? ''); ?> <span class="text-muted"><?php echo $this->escape($job->city ?? ''); ?></span></div>
|
||||
<?php endforeach; else : ?><div class="ms-3 small text-muted">No jobs</div><?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div></div></div>
|
||||
<div class="col-lg-4"><div class="card shadow-sm"><div class="card-header"><h5 class="mb-0">Unassigned (<?php echo count($unassigned); ?>)</h5></div><div class="card-body p-0">
|
||||
<?php foreach ($unassigned as $u) : ?>
|
||||
<div class="p-2 border-bottom"><strong class="small"><?php echo $this->escape($u->customer_name ?? ''); ?></strong><br><small class="text-muted"><?php echo $this->escape($u->category ?? $u->trade); ?></small></div>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($unassigned)) : ?><div class="p-3 text-muted text-center">All assigned</div><?php endif; ?>
|
||||
</div></div></div>
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
$board=$this->board;$s=$this->stats;
|
||||
?>
|
||||
<div class="row g-3 mb-4"><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int)$s->total_today; ?></div><small>Today</small></div></div></div><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-danger"><?php echo (int)$s->urgent; ?></div><small>Urgent</small></div></div></div><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-warning"><?php echo (int)$s->en_route; ?></div><small>En Route</small></div></div></div><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-success"><?php echo (int)$s->completed; ?></div><small>Done</small></div></div></div></div>
|
||||
<?php foreach($board as $tech): ?>
|
||||
<div class="card shadow-sm mb-2"><div class="card-body p-2"><strong><?php echo $this->escape($tech->tech_name); ?></strong>
|
||||
<?php foreach($tech->jobs as $job): ?><div class="ms-3 small"><?php echo $this->escape($job->wo_number); ?> <?php echo $this->escape($job->customer_name); ?></div><?php endforeach; ?>
|
||||
</div></div>
|
||||
<?php endforeach; ?>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
$equip=$this->equipment;$due=$this->serviceDue;
|
||||
?>
|
||||
<?php if(!empty($due)): ?><div class="alert alert-warning"><?php echo count($due); ?> equipment due</div><?php endif; ?>
|
||||
<table class="table table-striped"><thead><tr><th>Type</th><th>Make/Model</th><th>Serial</th><th>Owner</th><th>Last Service</th></tr></thead><tbody>
|
||||
<?php foreach($equip as $e): ?>
|
||||
<tr><td><?php echo ucfirst(str_replace("_"," ",$e->equipment_type)); ?></td><td><?php echo $this->escape($e->make." ".$e->model); ?></td><td><code><?php echo $this->escape($e->serial_number); ?></code></td><td><?php echo $this->escape($e->owner_name); ?></td><td><?php echo $e->last_service_date?date("M j",strtotime($e->last_service_date)):"Never"; ?></td></tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody></table>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php defined('_JEXEC') or die; $agreements=$this->agreements; $rev=$this->revenue; ?>
|
||||
<div class="row g-3 mb-4"><div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int)$rev->active_agreements; ?></div><small>Active Agreements</small></div></div></div><div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-success">$<?php echo number_format((float)$rev->annual_recurring,0); ?></div><small>Annual Recurring</small></div></div></div><div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold">$<?php echo number_format((float)$rev->monthly_recurring,0); ?></div><small>Monthly</small></div></div></div></div>
|
||||
<table class="table table-striped"><thead class="table-light"><tr><th>Agreement</th><th>Customer</th><th>Trade</th><th>Visits</th><th>Annual</th><th>Status</th><th>Expires</th></tr></thead><tbody>
|
||||
<?php foreach($agreements as $a): ?>
|
||||
<tr><td><strong><?php echo htmlspecialchars($a->title); ?></strong></td><td><?php echo htmlspecialchars($a->customer_name??""); ?></td><td><?php echo ucfirst($a->trade); ?></td><td><?php echo $a->visits_remaining; ?> of <?php echo (int)$a->visits_per_year; ?> left</td><td>$<?php echo number_format((float)$a->annual_amount,0); ?></td><td><span class="badge bg-<?php echo $a->status==="active"?"success":"warning"; ?>"><?php echo ucfirst($a->status); ?></span></td><td><?php echo $a->end_date?date("M j, Y",strtotime($a->end_date)):"—"; ?></td></tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if(empty($agreements)): ?><tr><td colspan="7" class="text-muted text-center py-4">No agreements</td></tr><?php endif; ?>
|
||||
</tbody></table>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php defined('_JEXEC') or die; $techs=$this->technicians; $statusColors=["available"=>"success","dispatched"=>"info","en_route"=>"warning","on_site"=>"primary","off_duty"=>"secondary","on_leave"=>"dark"]; ?>
|
||||
<table class="table table-striped table-hover"><thead class="table-light"><tr><th>Tech</th><th>Trade</th><th>Status</th><th>Phone</th><th>Vehicle</th><th>License</th><th>Jobs/Month</th></tr></thead><tbody>
|
||||
<?php foreach($techs as $t): ?>
|
||||
<tr><td><strong><?php echo htmlspecialchars($t->tech_name??""); ?></strong></td><td><?php echo ucfirst($t->trade); ?></td><td><span class="badge bg-<?php echo $statusColors[$t->status]??"secondary"; ?>"><?php echo ucfirst(str_replace("_"," ",$t->status)); ?></span></td><td class="small"><?php echo htmlspecialchars($t->telephone??""); ?></td><td><?php echo htmlspecialchars($t->vehicle_number??"—"); ?></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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
$vehicles=$this->vehicles;
|
||||
?>
|
||||
<table class="table table-striped"><thead><tr><th>Vehicle</th><th>Make/Model</th><th>Assigned To</th><th>Mileage</th><th>Status</th></tr></thead><tbody>
|
||||
<?php foreach($vehicles as $v): ?>
|
||||
<tr><td><strong><?php echo $this->escape($v->vehicle_number); ?></strong></td><td><?php echo $this->escape($v->make." ".$v->model); ?></td><td><?php echo $this->escape($v->assigned_tech_name); ?></td><td><?php echo $v->mileage?number_format((int)$v->mileage):"—"; ?></td><td><?php echo ucfirst($v->status); ?></td></tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody></table>
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Router\Route;
|
||||
$orders = $this->orders;
|
||||
$f = $this->filters;
|
||||
$statusColors = ['new'=>'secondary','dispatched'=>'info','en_route'=>'warning','on_site'=>'primary','in_progress'=>'primary','parts_needed'=>'danger','completed'=>'success','invoiced'=>'dark','cancelled'=>'danger'];
|
||||
$priorityColors = ['emergency'=>'danger','urgent'=>'warning','high'=>'info','normal'=>'primary','low'=>'secondary','scheduled'=>'dark'];
|
||||
?>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitefield&view=workorders'); ?>" method="post" name="adminForm" id="adminForm">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-auto"><select name="filter_status" class="form-select form-select-sm" onchange="this.form.submit()"><option value="">All Statuses</option>
|
||||
<?php foreach($statusColors as $k=>$v): ?><option value="<?php echo $k; ?>" <?php echo $f['status']===$k?'selected':''; ?>><?php echo ucfirst(str_replace('_',' ',$k)); ?></option><?php endforeach; ?>
|
||||
</select></div>
|
||||
<div class="col-auto"><input type="date" name="filter_date" class="form-control form-control-sm" value="<?php echo $this->escape($f['date']); ?>" onchange="this.form.submit()" /></div>
|
||||
<div class="col-auto"><input type="text" name="filter_search" class="form-control form-control-sm" placeholder="Search..." value="<?php echo $this->escape($f['search']); ?>" /></div>
|
||||
<div class="col-auto"><button type="submit" class="btn btn-sm btn-outline-primary">Filter</button></div>
|
||||
</div>
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-light"><tr><th>WO#</th><th>Customer</th><th>Trade</th><th>Priority</th><th>Status</th><th>Technician</th><th>Scheduled</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach($orders as $wo): ?>
|
||||
<tr><td><code><?php echo $this->escape($wo->wo_number); ?></code></td>
|
||||
<td><?php echo $this->escape($wo->customer_name??''); ?><br><small class="text-muted"><?php echo $this->escape($wo->city??''); ?></small></td>
|
||||
<td><?php echo ucfirst($wo->trade); ?></td>
|
||||
<td><span class="badge bg-<?php echo $priorityColors[$wo->priority]??'secondary'; ?>"><?php echo ucfirst($wo->priority); ?></span></td>
|
||||
<td><span class="badge bg-<?php echo $statusColors[$wo->status]??'secondary'; ?>"><?php echo ucfirst(str_replace('_',' ',$wo->status)); ?></span></td>
|
||||
<td><?php echo $this->escape($wo->tech_name??'Unassigned'); ?></td>
|
||||
<td><?php echo $wo->scheduled_date?date('M j',strtotime($wo->scheduled_date)):'—'; ?></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>
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Equipment + Vehicles + Service Agreements API.
|
||||
*
|
||||
* GET /equipment — List equipment
|
||||
* GET /equipment/{id} — Equipment detail with service history
|
||||
* GET /vehicles — List vehicles (fleet)
|
||||
* GET /vehicles/{id}/stock — Truck stock for a vehicle
|
||||
* GET /agreements — List service agreements
|
||||
* GET /agreements/{id} — Agreement detail with WO history
|
||||
*/
|
||||
class FieldEquipmentController extends BaseController
|
||||
{
|
||||
private function requireAuth(string $action = 'core.manage'): void
|
||||
{
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Access denied.']);
|
||||
Factory::getApplication()->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function listEquipment(): void
|
||||
{
|
||||
$this->requireAuth('core.manage');
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('eq.*, loc.name AS location_name, loc.address')
|
||||
->select('cd.name AS customer_name')
|
||||
->from($db->quoteName('#__mokosuitefield_equipment', 'eq'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = eq.location_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = eq.contact_id')
|
||||
->order('eq.name ASC');
|
||||
|
||||
$type = $input->getString('type', '');
|
||||
if ($type) $query->where($db->quoteName('eq.type') . ' = ' . $db->quote($type));
|
||||
|
||||
$status = $input->getString('status', '');
|
||||
if ($status) $query->where($db->quoteName('eq.status') . ' = ' . $db->quote($status));
|
||||
|
||||
$locationId = $input->getInt('location_id', 0);
|
||||
if ($locationId) $query->where('eq.location_id = ' . $locationId);
|
||||
|
||||
$db->setQuery($query, 0, 100);
|
||||
$this->sendJson($db->loadObjectList() ?: []);
|
||||
}
|
||||
|
||||
public function getEquipment(): void
|
||||
{
|
||||
$this->requireAuth('core.manage');
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('eq.*, loc.name AS location_name, loc.address, cd.name AS customer_name')
|
||||
->from($db->quoteName('#__mokosuitefield_equipment', 'eq'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = eq.location_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = eq.contact_id')
|
||||
->where('eq.id = ' . $id));
|
||||
$equipment = $db->loadObject();
|
||||
|
||||
if (!$equipment) {
|
||||
http_response_code(404);
|
||||
$this->sendJson(['error' => 'Equipment not found']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Service history
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('wo.id, wo.wo_number, wo.description, wo.status, wo.completed_at, wo.trade')
|
||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
||||
->where('wo.equipment_id = ' . $id)
|
||||
->order('wo.scheduled_date DESC'), 0, 20);
|
||||
$equipment->service_history = $db->loadObjectList() ?: [];
|
||||
|
||||
$this->sendJson($equipment);
|
||||
}
|
||||
|
||||
public function listVehicles(): void
|
||||
{
|
||||
$this->requireAuth('core.manage');
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('v.*, t_cd.name AS tech_name')
|
||||
->from($db->quoteName('#__mokosuitefield_vehicles', 'v'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.technician_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id')
|
||||
->order('v.vehicle_name ASC'));
|
||||
|
||||
$this->sendJson($db->loadObjectList() ?: []);
|
||||
}
|
||||
|
||||
public function truckStock(): void
|
||||
{
|
||||
$this->requireAuth('core.manage');
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$vehicleId = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('ts.*, p.title AS product_name')
|
||||
->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id')
|
||||
->where('ts.vehicle_id = ' . $vehicleId)
|
||||
->order('p.title ASC'));
|
||||
|
||||
$this->sendJson($db->loadObjectList() ?: []);
|
||||
}
|
||||
|
||||
public function listAgreements(): void
|
||||
{
|
||||
$this->requireAuth('core.manage');
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('sa.*, cd.name AS customer_name, loc.address')
|
||||
->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = sa.location_id')
|
||||
->order('sa.end_date ASC');
|
||||
|
||||
$status = $input->getString('status', '');
|
||||
if ($status) $query->where($db->quoteName('sa.status') . ' = ' . $db->quote($status));
|
||||
|
||||
$db->setQuery($query, 0, 100);
|
||||
$this->sendJson($db->loadObjectList() ?: []);
|
||||
}
|
||||
|
||||
public function getAgreement(): void
|
||||
{
|
||||
$this->requireAuth('core.manage');
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('sa.*, cd.name AS customer_name, loc.name AS location_name, loc.address')
|
||||
->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = sa.location_id')
|
||||
->where('sa.id = ' . $id));
|
||||
$agreement = $db->loadObject();
|
||||
|
||||
if (!$agreement) {
|
||||
http_response_code(404);
|
||||
$this->sendJson(['error' => 'Agreement not found']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Work orders under this agreement
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('wo.id, wo.wo_number, wo.description, wo.status, wo.scheduled_date, wo.trade')
|
||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
||||
->where('wo.agreement_id = ' . $id)
|
||||
->order('wo.scheduled_date DESC'), 0, 30);
|
||||
$agreement->work_orders = $db->loadObjectList() ?: [];
|
||||
|
||||
$this->sendJson($agreement);
|
||||
}
|
||||
|
||||
private function sendJson(mixed $data): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
Factory::getApplication()->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Estimates + Route API.
|
||||
*
|
||||
* GET /estimates — List estimates
|
||||
* POST /estimates — Create estimate from field
|
||||
* PATCH /estimates/{id}/status — Update estimate status (approve/reject)
|
||||
* POST /estimates/{id}/convert— Convert estimate to work order
|
||||
* GET /route/{techId} — Get optimized daily route
|
||||
* POST /route/{techId}/optimize — Trigger route optimization
|
||||
*/
|
||||
class FieldEstimatesController extends BaseController
|
||||
{
|
||||
private function requireAuth(string $action = 'core.manage'): void
|
||||
{
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Access denied.']);
|
||||
Factory::getApplication()->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function listEstimates(): void
|
||||
{
|
||||
$this->requireAuth('core.manage');
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('e.*, cd.name AS customer_name, loc.address')
|
||||
->from($db->quoteName('#__mokosuitefield_estimates', 'e'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id')
|
||||
->order('e.created DESC');
|
||||
|
||||
$status = $input->getString('status', '');
|
||||
if ($status) $query->where($db->quoteName('e.status') . ' = ' . $db->quote($status));
|
||||
|
||||
$techId = $input->getInt('technician_id', 0);
|
||||
if ($techId) $query->where('e.technician_id = ' . $techId);
|
||||
|
||||
$db->setQuery($query, 0, 100);
|
||||
$this->sendJson($db->loadObjectList() ?: []);
|
||||
}
|
||||
|
||||
public function createEstimate(): void
|
||||
{
|
||||
$this->requireAuth('core.create');
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$estimateId = \Moko\Plugin\System\MokoSuiteField\Helper\EstimateHelper::createEstimate(
|
||||
$input->getInt('contact_id', 0),
|
||||
$input->getInt('location_id', 0),
|
||||
$input->getString('trade', 'general'),
|
||||
$input->getString('description', ''),
|
||||
json_decode($input->getString('line_items', '[]'), true) ?: []
|
||||
);
|
||||
|
||||
$this->sendJson(['success' => true, 'estimate_id' => $estimateId]);
|
||||
}
|
||||
|
||||
public function updateStatus(): void
|
||||
{
|
||||
$this->requireAuth('core.edit');
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$id = $input->getInt('id', 0);
|
||||
$status = $input->getString('status', '');
|
||||
|
||||
if (!in_array($status, ['sent', 'approved', 'rejected', 'expired'])) {
|
||||
http_response_code(400);
|
||||
$this->sendJson(['error' => 'Invalid status']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$update = (object) [
|
||||
'id' => $id,
|
||||
'status' => $status,
|
||||
];
|
||||
|
||||
if ($status === 'approved') {
|
||||
$update->approved_at = Factory::getDate()->toSql();
|
||||
}
|
||||
|
||||
$db->updateObject('#__mokosuitefield_estimates', $update, 'id');
|
||||
$this->sendJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function convertToWorkOrder(): void
|
||||
{
|
||||
$this->requireAuth('core.create');
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
|
||||
$woId = \Moko\Plugin\System\MokoSuiteField\Helper\EstimateHelper::convertToWorkOrder($id);
|
||||
|
||||
if (!$woId) {
|
||||
http_response_code(400);
|
||||
$this->sendJson(['error' => 'Could not convert estimate']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendJson(['success' => true, 'work_order_id' => $woId]);
|
||||
}
|
||||
|
||||
public function getRoute(): void
|
||||
{
|
||||
$this->requireAuth('core.manage');
|
||||
$techId = Factory::getApplication()->getInput()->getInt('tech_id', 0);
|
||||
$date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d'));
|
||||
|
||||
$route = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::getTechRoute($techId, $date);
|
||||
$metrics = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::estimateRouteMetrics($techId, $date);
|
||||
|
||||
$this->sendJson([
|
||||
'route' => $route,
|
||||
'metrics' => $metrics,
|
||||
]);
|
||||
}
|
||||
|
||||
public function optimizeRoute(): void
|
||||
{
|
||||
$this->requireAuth('core.manage');
|
||||
$techId = Factory::getApplication()->getInput()->getInt('tech_id', 0);
|
||||
$date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d'));
|
||||
|
||||
$optimized = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::optimizeRoute($techId, $date);
|
||||
$metrics = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::estimateRouteMetrics($techId, $date);
|
||||
|
||||
$this->sendJson([
|
||||
'route' => $optimized,
|
||||
'metrics' => $metrics,
|
||||
]);
|
||||
}
|
||||
|
||||
private function sendJson(mixed $data): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
Factory::getApplication()->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Mobile tech API — GPS tracking, photo upload, time tracking, parts usage.
|
||||
* Designed for offline-capable mobile apps (queue + sync).
|
||||
*
|
||||
* GET /mobile/jobs — Today's jobs for authenticated tech
|
||||
* POST /mobile/status — Update WO status with GPS
|
||||
* POST /mobile/photo — Upload work order photo
|
||||
* POST /mobile/time/start — Start time entry
|
||||
* POST /mobile/time/stop — Stop time entry
|
||||
* POST /mobile/part — Log part usage from truck stock
|
||||
* POST /mobile/location — GPS heartbeat
|
||||
* GET /mobile/equipment/{qr} — QR code equipment lookup
|
||||
*/
|
||||
class FieldMobileController extends BaseController
|
||||
{
|
||||
private function requireTech(): object
|
||||
{
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
if (!$user || $user->guest) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Authentication required.']);
|
||||
Factory::getApplication()->close();
|
||||
}
|
||||
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('t.*, cd.name AS tech_name')
|
||||
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
|
||||
->join('INNER', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->where('cd.user_id = ' . (int) $user->id));
|
||||
$tech = $db->loadObject();
|
||||
|
||||
if (!$tech) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'No technician profile.']);
|
||||
Factory::getApplication()->close();
|
||||
}
|
||||
|
||||
return $tech;
|
||||
}
|
||||
|
||||
public function myJobs(): void
|
||||
{
|
||||
$tech = $this->requireTech();
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('wo.*, cd.name AS customer_name, cd.telephone AS customer_phone')
|
||||
->select('loc.address, loc.city, loc.state, loc.zip, loc.latitude, loc.longitude, loc.access_notes')
|
||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
|
||||
->where('wo.technician_id = ' . (int) $tech->id)
|
||||
->where('(wo.scheduled_date = ' . $db->quote($today) . ' OR wo.status IN (' . $db->quote('dispatched') . ',' . $db->quote('en_route') . ',' . $db->quote('on_site') . ',' . $db->quote('in_progress') . '))')
|
||||
->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ') ASC'));
|
||||
|
||||
$this->sendJson($db->loadObjectList() ?: []);
|
||||
}
|
||||
|
||||
public function updateStatus(): void
|
||||
{
|
||||
$tech = $this->requireTech();
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
\Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::updateStatus(
|
||||
$input->getInt('work_order_id', 0),
|
||||
$input->getString('status', ''),
|
||||
$input->getFloat('lat', 0) ?: null,
|
||||
$input->getFloat('lng', 0) ?: null
|
||||
);
|
||||
|
||||
$this->sendJson(['message' => 'Status updated.']);
|
||||
}
|
||||
|
||||
public function uploadPhoto(): void
|
||||
{
|
||||
$tech = $this->requireTech();
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$woId = $input->getInt('work_order_id', 0);
|
||||
$photoType = $input->getString('photo_type', 'other');
|
||||
$caption = $input->getString('caption', '');
|
||||
$lat = $input->getFloat('lat', 0) ?: null;
|
||||
$lng = $input->getFloat('lng', 0) ?: null;
|
||||
|
||||
// Handle file upload
|
||||
$file = $input->files->get('photo');
|
||||
if (!$file || $file['error'] !== 0) {
|
||||
http_response_code(400);
|
||||
$this->sendJson(['error' => 'No photo uploaded.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$uploadDir = 'media/com_mokosuitefield/photos/' . date('Y/m/');
|
||||
if (!is_dir(JPATH_ROOT . '/' . $uploadDir)) {
|
||||
mkdir(JPATH_ROOT . '/' . $uploadDir, 0755, true);
|
||||
}
|
||||
|
||||
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
|
||||
$filename = 'wo' . $woId . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
|
||||
$filePath = $uploadDir . $filename;
|
||||
|
||||
move_uploaded_file($file['tmp_name'], JPATH_ROOT . '/' . $filePath);
|
||||
|
||||
$db->insertObject('#__mokosuitefield_wo_photos', (object) [
|
||||
'work_order_id' => $woId,
|
||||
'file_path' => $filePath,
|
||||
'photo_type' => $photoType,
|
||||
'caption' => $caption,
|
||||
'latitude' => $lat,
|
||||
'longitude' => $lng,
|
||||
'taken_at' => Factory::getDate()->toSql(),
|
||||
'uploaded_by' => Factory::getApplication()->getIdentity()->id,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
], 'id');
|
||||
|
||||
$this->sendJson(['message' => 'Photo uploaded.', 'path' => $filePath]);
|
||||
}
|
||||
|
||||
public function startTime(): void
|
||||
{
|
||||
$tech = $this->requireTech();
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->insertObject('#__mokosuitefield_time_entries', (object) [
|
||||
'work_order_id' => $input->getInt('work_order_id', 0),
|
||||
'technician_id' => $tech->id,
|
||||
'start_time' => Factory::getDate()->toSql(),
|
||||
'is_travel' => $input->getInt('is_travel', 0),
|
||||
'rate' => $tech->hourly_rate,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
], 'id');
|
||||
|
||||
$this->sendJson(['message' => 'Timer started.']);
|
||||
}
|
||||
|
||||
public function stopTime(): void
|
||||
{
|
||||
$tech = $this->requireTech();
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$entryId = $input->getInt('entry_id', 0);
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
$db->setQuery($db->getQuery(true)->select('start_time, rate')->from('#__mokosuitefield_time_entries')->where('id = ' . $entryId));
|
||||
$entry = $db->loadObject();
|
||||
|
||||
if (!$entry) {
|
||||
http_response_code(404);
|
||||
$this->sendJson(['error' => 'Time entry not found.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$hours = round((strtotime($now) - strtotime($entry->start_time)) / 3600, 2);
|
||||
|
||||
$db->updateObject('#__mokosuitefield_time_entries', (object) [
|
||||
'id' => $entryId,
|
||||
'end_time' => $now,
|
||||
'hours' => $hours,
|
||||
], 'id');
|
||||
|
||||
$this->sendJson(['message' => 'Timer stopped.', 'hours' => $hours]);
|
||||
}
|
||||
|
||||
public function logPart(): void
|
||||
{
|
||||
$tech = $this->requireTech();
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$productId = $input->getInt('product_id', 0);
|
||||
$qty = $input->getFloat('quantity', 1);
|
||||
$woId = $input->getInt('work_order_id', 0);
|
||||
|
||||
// Deduct from truck stock
|
||||
\Moko\Plugin\System\MokoSuiteField\Helper\TruckStockHelper::usePart(
|
||||
(int) $tech->vehicle_id, $productId, $qty
|
||||
);
|
||||
|
||||
// Add as WO line item
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$db->setQuery($db->getQuery(true)->select('name, cost_price, price')->from('#__mokosuite_crm_products')->where('id = ' . $productId));
|
||||
$product = $db->loadObject();
|
||||
|
||||
if ($product) {
|
||||
$db->insertObject('#__mokosuitefield_wo_items', (object) [
|
||||
'work_order_id' => $woId,
|
||||
'item_type' => 'part',
|
||||
'product_id' => $productId,
|
||||
'description' => $product->name,
|
||||
'quantity' => $qty,
|
||||
'unit_price' => (float) $product->price,
|
||||
'line_total' => $qty * (float) $product->price,
|
||||
'from_truck_stock' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->sendJson(['message' => 'Part logged.']);
|
||||
}
|
||||
|
||||
public function gpsHeartbeat(): void
|
||||
{
|
||||
$tech = $this->requireTech();
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->updateObject('#__mokosuitefield_technicians', (object) [
|
||||
'id' => $tech->id,
|
||||
'current_lat' => $input->getFloat('lat', 0),
|
||||
'current_lng' => $input->getFloat('lng', 0),
|
||||
'last_location_update' => Factory::getDate()->toSql(),
|
||||
], 'id');
|
||||
|
||||
$this->sendJson(['message' => 'Location updated.']);
|
||||
}
|
||||
|
||||
public function equipmentLookup(): void
|
||||
{
|
||||
$this->requireTech();
|
||||
$qr = Factory::getApplication()->getInput()->getString('qr', '');
|
||||
|
||||
$equipment = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getByQrCode($qr);
|
||||
|
||||
if (!$equipment) {
|
||||
http_response_code(404);
|
||||
$this->sendJson(['error' => 'Equipment not found.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendJson($equipment);
|
||||
}
|
||||
|
||||
private function sendJson(mixed $data): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$app->getDocument()->setMimeEncoding('application/json');
|
||||
echo json_encode(['data' => $data], JSON_THROW_ON_ERROR);
|
||||
$app->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Work Order + Dispatch API.
|
||||
*
|
||||
* GET /workorders — List work orders
|
||||
* POST /workorders — Create work order
|
||||
* PATCH /workorders/{id}/status — Update status (with GPS)
|
||||
* POST /workorders/{id}/dispatch — Dispatch to technician
|
||||
* GET /dispatch/board — Today's dispatch board
|
||||
* GET /technicians/available — Available techs by trade
|
||||
*/
|
||||
class FieldWorkOrderController extends BaseController
|
||||
{
|
||||
private function requireAuth(string $action = 'core.manage'): void
|
||||
{
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Access denied.']);
|
||||
Factory::getApplication()->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function list(): void
|
||||
{
|
||||
$this->requireAuth('core.manage');
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$status = $input->getString('status', '');
|
||||
$techId = $input->getInt('technician_id', 0);
|
||||
$date = $input->getString('date', '');
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('wo.*, cd.name AS customer_name, loc.address, loc.city, t_cd.name AS tech_name')
|
||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id')
|
||||
->order('wo.scheduled_date ASC, wo.scheduled_time_start ASC');
|
||||
|
||||
if ($status) $query->where($db->quoteName('wo.status') . ' = ' . $db->quote($status));
|
||||
if ($techId) $query->where('wo.technician_id = ' . $techId);
|
||||
if ($date) $query->where('wo.scheduled_date = ' . $db->quote($date));
|
||||
|
||||
$db->setQuery($query, 0, 100);
|
||||
$this->sendJson($db->loadObjectList() ?: []);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->requireAuth('core.create');
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$woId = \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::create(
|
||||
$input->getInt('contact_id', 0),
|
||||
$input->getString('trade', 'general'),
|
||||
$input->getString('description', ''),
|
||||
[
|
||||
'location_id' => $input->getInt('location_id', 0),
|
||||
'priority' => $input->getString('priority', 'normal'),
|
||||
'category' => $input->getString('category', ''),
|
||||
'scheduled_date' => $input->getString('scheduled_date', ''),
|
||||
'time_start' => $input->getString('time_start', ''),
|
||||
'source' => $input->getString('source', 'phone'),
|
||||
]
|
||||
);
|
||||
|
||||
$this->sendJson(['id' => $woId, 'message' => 'Work order created.']);
|
||||
}
|
||||
|
||||
public function updateStatus(): void
|
||||
{
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
\Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::updateStatus(
|
||||
$input->getInt('id', 0),
|
||||
$input->getString('status', ''),
|
||||
$input->getFloat('lat', 0) ?: null,
|
||||
$input->getFloat('lng', 0) ?: null
|
||||
);
|
||||
|
||||
$this->sendJson(['message' => 'Status updated.']);
|
||||
}
|
||||
|
||||
public function dispatchToTech(): void
|
||||
{
|
||||
$this->requireAuth('field.dispatch');
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
\Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::dispatch(
|
||||
$input->getInt('work_order_id', 0),
|
||||
$input->getInt('technician_id', 0)
|
||||
);
|
||||
|
||||
$this->sendJson(['message' => 'Dispatched.']);
|
||||
}
|
||||
|
||||
public function board(): void
|
||||
{
|
||||
$date = Factory::getApplication()->getInput()->getString('date', '');
|
||||
$this->sendJson(\Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getDispatchBoard($date));
|
||||
}
|
||||
|
||||
public function availableTechs(): void
|
||||
{
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$tech = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::findBestTech(
|
||||
$input->getString('trade', 'general'),
|
||||
$input->getString('zip', '')
|
||||
);
|
||||
|
||||
$this->sendJson($tech ? [$tech] : []);
|
||||
}
|
||||
|
||||
private function sendJson(mixed $data): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$app->getDocument()->setMimeEncoding('application/json');
|
||||
echo json_encode(['data' => $data], JSON_THROW_ON_ERROR);
|
||||
$app->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* MokoSuite Field Service Styles */
|
||||
.dispatch-board .tech-card { border-left: 4px solid #198754; }
|
||||
.dispatch-board .tech-card.dispatched { border-left-color: #0d6efd; }
|
||||
.dispatch-board .tech-card.on-site { border-left-color: #ffc107; }
|
||||
.wo-priority-emergency { background-color: rgba(220, 53, 69, 0.1) !important; }
|
||||
.wo-priority-urgent { background-color: rgba(255, 193, 7, 0.08) !important; }
|
||||
.tech-mobile .current-job { border: 2px solid #0d6efd; }
|
||||
@media (max-width: 768px) { .tech-mobile .card { margin-bottom: 0.5rem; } }
|
||||
@media print { .btn, .toolbar { display: none !important; } }
|
||||
@@ -0,0 +1,6 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-refresh dispatch board every 30 seconds
|
||||
if (document.querySelector('.dispatch-board')) {
|
||||
setInterval(function() { location.reload(); }, 30000);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Site\Controller;
|
||||
defined('_JEXEC') or die;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
class DisplayController extends BaseController
|
||||
{
|
||||
protected $default_view = 'bookservice';
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Site\Service;
|
||||
defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Component\Router\RouterBase;
|
||||
class Router extends RouterBase
|
||||
{
|
||||
public function build(&$query): array
|
||||
{
|
||||
$segments = [];
|
||||
if (isset($query['view'])) { $segments[] = $query['view']; unset($query['view']); }
|
||||
if (isset($query['id'])) { $segments[] = $query['id']; unset($query['id']); }
|
||||
return $segments;
|
||||
}
|
||||
public function parse(&$segments): array
|
||||
{
|
||||
$vars = [];
|
||||
if (!empty($segments[0])) $vars['view'] = array_shift($segments);
|
||||
if (!empty($segments[0]) && is_numeric($segments[0])) $vars['id'] = (int) array_shift($segments);
|
||||
return $vars;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Site\View\BookService;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Public service booking page — customers request service online.
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
public bool $submitted = false;
|
||||
public string $companyName = '';
|
||||
public array $trades = [];
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$params = $app->getParams('com_mokosuitefield');
|
||||
$this->companyName = $params->get('company_name', $app->get('sitename'));
|
||||
|
||||
$this->trades = [
|
||||
'plumbing' => 'Plumbing',
|
||||
'electrical' => 'Electrical',
|
||||
'hvac' => 'HVAC / Heating & Cooling',
|
||||
'appliance' => 'Appliance Repair',
|
||||
'general' => 'General Maintenance',
|
||||
'carpentry' => 'Carpentry',
|
||||
'painting' => 'Painting',
|
||||
'roofing' => 'Roofing',
|
||||
'landscaping' => 'Landscaping',
|
||||
'locksmith' => 'Locksmith',
|
||||
];
|
||||
|
||||
if ($app->getInput()->getMethod() === 'POST' && \Joomla\CMS\Session\Session::checkToken()) {
|
||||
$input = $app->getInput();
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$name = $input->getString('name', '');
|
||||
$email = $input->getString('email', '');
|
||||
$phone = $input->getString('phone', '');
|
||||
$address = $input->getString('address', '');
|
||||
$trade = $input->getString('trade', 'general');
|
||||
$desc = $input->getString('description', '');
|
||||
$priority = $input->getString('priority', 'normal');
|
||||
|
||||
if ($name && $phone && $desc) {
|
||||
// Find or create contact
|
||||
$db->setQuery($db->getQuery(true)->select('id')->from('#__contact_details')
|
||||
->where($db->quoteName('telephone') . ' = ' . $db->quote($phone)));
|
||||
$contactId = (int) $db->loadResult();
|
||||
|
||||
if (!$contactId) {
|
||||
$db->insertObject('#__contact_details', (object) [
|
||||
'name' => $name, 'email_to' => $email, 'telephone' => $phone,
|
||||
'address' => $address, 'published' => 1, 'created' => Factory::getDate()->toSql(),
|
||||
], 'id');
|
||||
$contactId = $db->insertid();
|
||||
}
|
||||
|
||||
// Create work order
|
||||
\Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::create(
|
||||
(int) $contactId, $trade, $desc, [
|
||||
'priority' => $priority,
|
||||
'source' => 'website',
|
||||
]
|
||||
);
|
||||
|
||||
$this->submitted = true;
|
||||
}
|
||||
}
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Site\View\CustomerPortal;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Customer portal — view work order status, service history, equipment, agreements.
|
||||
* "When is the tech coming?" and "What was done?" — self-service answers.
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
public ?int $contactId = null;
|
||||
public array $activeOrders = [];
|
||||
public array $orderHistory = [];
|
||||
public array $equipment = [];
|
||||
public array $agreements = [];
|
||||
public ?object $nextService = null;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$user = $app->getIdentity();
|
||||
|
||||
if (!$user || $user->guest) {
|
||||
$app->redirect('index.php?option=com_users&view=login');
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
// Resolve contact from Joomla user
|
||||
$db->setQuery($db->getQuery(true)->select('id')->from('#__contact_details')->where('user_id = ' . (int) $user->id));
|
||||
$this->contactId = (int) $db->loadResult();
|
||||
|
||||
if (!$this->contactId) {
|
||||
$app->enqueueMessage('No customer profile found.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Active work orders (in-progress)
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('wo.*, t_cd.name AS tech_name, t.trade AS tech_trade, loc.address')
|
||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
|
||||
->where('wo.contact_id = ' . $this->contactId)
|
||||
->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('completed') . ',' . $db->quote('invoiced') . ',' . $db->quote('cancelled') . ')')
|
||||
->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ') ASC'));
|
||||
$this->activeOrders = $db->loadObjectList() ?: [];
|
||||
|
||||
// Recent completed orders (last 10)
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('wo.wo_number, wo.trade, wo.category, wo.total, wo.actual_departure AS completed_at, wo.work_performed')
|
||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
||||
->where('wo.contact_id = ' . $this->contactId)
|
||||
->where($db->quoteName('wo.status') . ' IN (' . $db->quote('completed') . ',' . $db->quote('invoiced') . ')')
|
||||
->order('wo.actual_departure DESC'), 0, 10);
|
||||
$this->orderHistory = $db->loadObjectList() ?: [];
|
||||
|
||||
// Equipment at customer locations
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('e.*, loc.address')
|
||||
->from($db->quoteName('#__mokosuitefield_equipment', 'e'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id')
|
||||
->where('e.contact_id = ' . $this->contactId)
|
||||
->order('e.equipment_type ASC'));
|
||||
$this->equipment = $db->loadObjectList() ?: [];
|
||||
|
||||
// Active service agreements
|
||||
$this->agreements = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getActiveAgreements($this->contactId);
|
||||
|
||||
// Next scheduled service
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('wo.wo_number, wo.scheduled_date, wo.scheduled_time_start, wo.trade, wo.category')
|
||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
||||
->where('wo.contact_id = ' . $this->contactId)
|
||||
->where($db->quoteName('wo.scheduled_date') . ' >= CURDATE()')
|
||||
->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ')')
|
||||
->order('wo.scheduled_date ASC, wo.scheduled_time_start ASC'), 0, 1);
|
||||
$this->nextService = $db->loadObject();
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoSuiteField\Site\View\TechMobile;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Technician mobile view — tablet/phone for field techs.
|
||||
* Shows today's jobs, GPS navigation, status updates, time tracking, photos.
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
public ?object $tech = null;
|
||||
public array $todayJobs = [];
|
||||
public ?object $currentJob = null;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$user = $app->getIdentity();
|
||||
|
||||
if (!$user || $user->guest) {
|
||||
$app->redirect('index.php?option=com_users&view=login');
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
// Find technician record for logged-in user
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('t.*, cd.name AS tech_name')
|
||||
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
|
||||
->join('INNER', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->join('INNER', $db->quoteName('#__users', 'u') . ' ON u.id = ' . (int) $user->id)
|
||||
->where('cd.user_id = ' . (int) $user->id));
|
||||
$this->tech = $db->loadObject();
|
||||
|
||||
if (!$this->tech) {
|
||||
$app->enqueueMessage('No technician profile found for your account.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
$today = date('Y-m-d');
|
||||
|
||||
// Today's assigned jobs
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('wo.*, cd.name AS customer_name, cd.telephone AS customer_phone')
|
||||
->select('loc.address, loc.city, loc.state, loc.zip, loc.latitude, loc.longitude, loc.access_notes')
|
||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
|
||||
->where('wo.technician_id = ' . (int) $this->tech->id)
|
||||
->where('(wo.scheduled_date = ' . $db->quote($today) . ' OR (wo.scheduled_date IS NULL AND wo.status NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ',' . $db->quote('invoiced') . ')))')
|
||||
->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ',' . $db->quote('low') . ') ASC, wo.scheduled_time_start ASC'));
|
||||
$this->todayJobs = $db->loadObjectList() ?: [];
|
||||
|
||||
// Current active job (en_route, on_site, or in_progress)
|
||||
foreach ($this->todayJobs as $job) {
|
||||
if (in_array($job->status, ['en_route', 'on_site', 'in_progress'])) {
|
||||
$this->currentJob = $job;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
$company = $this->companyName;
|
||||
$trades = $this->trades;
|
||||
if ($this->submitted) : ?>
|
||||
<div class="container py-5 text-center"><span class="icon-check-circle text-success" style="font-size:4rem;"></span><h2>Service Request Received</h2><p class="text-muted">We will contact you shortly to schedule your appointment.</p></div>
|
||||
<?php return; endif; ?>
|
||||
<div class="container py-4" style="max-width:600px;">
|
||||
<h2 class="text-center mb-2">Request Service</h2>
|
||||
<p class="text-center text-muted mb-4"><?php echo $this->escape($company); ?></p>
|
||||
<div class="card shadow-sm"><div class="card-body">
|
||||
<form method="post" action="<?php echo Uri::current(); ?>">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6"><label class="form-label">Name *</label><input type="text" name="name" class="form-control" required /></div>
|
||||
<div class="col-md-6"><label class="form-label">Phone *</label><input type="tel" name="phone" class="form-control" required /></div>
|
||||
</div>
|
||||
<div class="mb-3"><label class="form-label">Email</label><input type="email" name="email" class="form-control" /></div>
|
||||
<div class="mb-3"><label class="form-label">Service Address</label><textarea name="address" class="form-control" rows="2"></textarea></div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6"><label class="form-label">Service Type *</label><select name="trade" class="form-select">
|
||||
<?php foreach ($trades as $k=>$v) : ?><option value="<?php echo $k; ?>"><?php echo $v; ?></option><?php endforeach; ?>
|
||||
</select></div>
|
||||
<div class="col-md-6"><label class="form-label">Priority</label><select name="priority" class="form-select">
|
||||
<option value="normal">Normal</option><option value="urgent">Urgent</option><option value="emergency">Emergency</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="mb-3"><label class="form-label">Describe the problem *</label><textarea name="description" class="form-control" rows="4" required></textarea></div>
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100"><span class="icon-wrench"></span> Submit Service Request</button>
|
||||
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div></div>
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
if (!$this->contactId) return;
|
||||
$active=$this->activeOrders;$history=$this->orderHistory;$next=$this->nextService;
|
||||
?>
|
||||
<div class="container py-3"><h3>My Service Portal</h3>
|
||||
<?php if ($next): ?><div class="card shadow-sm mb-3 border-primary"><div class="card-body"><h5>Next Service</h5><strong><?php echo ucfirst($next->trade); ?></strong> — <?php echo date("l, F j",strtotime($next->scheduled_date)); ?></div></div><?php endif; ?>
|
||||
<?php if (!empty($active)): ?><div class="card shadow-sm mb-3"><div class="card-header bg-warning text-dark"><h5 class="mb-0">Active Work Orders</h5></div><div class="card-body p-0"><table class="table mb-0"><thead><tr><th>WO#</th><th>Service</th><th>Status</th></tr></thead><tbody>
|
||||
<?php foreach($active as $wo): ?><tr><td><code><?php echo $this->escape($wo->wo_number); ?></code></td><td><?php echo ucfirst($wo->trade); ?></td><td><span class="badge bg-primary"><?php echo ucfirst(str_replace("_"," ",$wo->status)); ?></span></td></tr><?php endforeach; ?>
|
||||
</tbody></table></div></div><?php endif; ?>
|
||||
<?php if (!empty($history)): ?><div class="card shadow-sm"><div class="card-header"><h5 class="mb-0">Service History</h5></div><div class="card-body p-0"><table class="table mb-0"><thead><tr><th>WO#</th><th>Service</th><th>Total</th><th>Date</th></tr></thead><tbody>
|
||||
<?php foreach($history as $h): ?><tr><td><code><?php echo $this->escape($h->wo_number); ?></code></td><td><?php echo ucfirst($h->trade); ?></td><td><?php echo (float)$h->total>0?"$".number_format((float)$h->total,2):""; ?></td><td><?php echo $h->completed_at?date("M j",strtotime($h->completed_at)):""; ?></td></tr><?php endforeach; ?>
|
||||
</tbody></table></div></div><?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
$tech = $this->tech;
|
||||
$jobs = $this->todayJobs;
|
||||
$current = $this->currentJob;
|
||||
if (!$tech) return;
|
||||
$statusColors = ['new'=>'secondary','dispatched'=>'info','en_route'=>'warning','on_site'=>'primary','in_progress'=>'primary','parts_needed'=>'danger','completed'=>'success'];
|
||||
?>
|
||||
<div class="container-fluid py-2" style="max-width:600px;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0"><?php echo $this->escape($tech->tech_name); ?></h4>
|
||||
<span class="badge bg-<?php echo $tech->status==='available'?'success':'warning'; ?> fs-6"><?php echo ucfirst(str_replace('_',' ',$tech->status)); ?></span>
|
||||
</div>
|
||||
|
||||
<?php if ($current) : ?>
|
||||
<div class="card shadow-sm mb-3 border-primary border-2">
|
||||
<div class="card-header bg-primary text-white"><strong>Current Job: <?php echo $this->escape($current->wo_number); ?></strong></div>
|
||||
<div class="card-body">
|
||||
<h5><?php echo $this->escape($current->customer_name); ?></h5>
|
||||
<p class="mb-1"><span class="icon-map-marker"></span> <?php echo $this->escape($current->address); ?>, <?php echo $this->escape($current->city); ?></p>
|
||||
<?php if ($current->customer_phone) : ?><p class="mb-1"><a href="tel:<?php echo $current->customer_phone; ?>" class="btn btn-sm btn-outline-primary"><span class="icon-phone"></span> <?php echo $current->customer_phone; ?></a></p><?php endif; ?>
|
||||
<?php if ($current->access_notes) : ?><div class="alert alert-warning small mb-2"><strong>Access:</strong> <?php echo $this->escape($current->access_notes); ?></div><?php endif; ?>
|
||||
<p class="small text-muted"><?php echo $this->escape($current->description); ?></p>
|
||||
<div class="d-flex gap-2">
|
||||
<?php if ($current->status === 'dispatched') : ?><button class="btn btn-warning flex-grow-1" onclick="updateStatus(<?php echo $current->id; ?>,'en_route')"><span class="icon-truck"></span> En Route</button><?php endif; ?>
|
||||
<?php if ($current->status === 'en_route') : ?><button class="btn btn-primary flex-grow-1" onclick="updateStatus(<?php echo $current->id; ?>,'on_site')"><span class="icon-map-marker"></span> Arrived</button><?php endif; ?>
|
||||
<?php if ($current->status === 'on_site') : ?><button class="btn btn-info flex-grow-1" onclick="updateStatus(<?php echo $current->id; ?>,'in_progress')"><span class="icon-wrench"></span> Start Work</button><?php endif; ?>
|
||||
<?php if ($current->status === 'in_progress') : ?><button class="btn btn-success flex-grow-1" onclick="updateStatus(<?php echo $current->id; ?>,'completed')"><span class="icon-check"></span> Complete</button><?php endif; ?>
|
||||
<?php if ($current->latitude && $current->longitude) : ?>
|
||||
<a href="https://maps.google.com/maps?daddr=<?php echo $current->latitude; ?>,<?php echo $current->longitude; ?>" target="_blank" class="btn btn-outline-dark"><span class="icon-compass"></span></a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h5>Today (<?php echo count($jobs); ?> jobs)</h5>
|
||||
<?php foreach ($jobs as $job) : if ($current && $job->id === $current->id) continue; ?>
|
||||
<div class="card shadow-sm mb-2">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between"><strong class="small"><?php echo $this->escape($job->customer_name); ?></strong><span class="badge bg-<?php echo $statusColors[$job->status]??'secondary'; ?>"><?php echo ucfirst(str_replace('_',' ',$job->status)); ?></span></div>
|
||||
<small class="text-muted"><?php echo $this->escape($job->city??''); ?> | <?php echo $job->scheduled_time_start?date('g:ia',strtotime($job->scheduled_time_start)):'TBD'; ?> | <?php echo ucfirst($job->trade); ?></small>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($jobs)) : ?><p class="text-muted text-center">No jobs today.</p><?php endif; ?>
|
||||
</div>
|
||||
<script>
|
||||
function updateStatus(woId, status) {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(function(pos) {
|
||||
fetch('index.php?option=com_mokosuitefield&task=api.updateStatus&id=' + woId + '&status=' + status + '&lat=' + pos.coords.latitude + '&lng=' + pos.coords.longitude, {method:'POST'}).then(function() { location.reload(); });
|
||||
}, function() { fetch('index.php?option=com_mokosuitefield&task=api.updateStatus&id=' + woId + '&status=' + status, {method:'POST'}).then(function() { location.reload(); }); });
|
||||
} else { fetch('index.php?option=com_mokosuitefield&task=api.updateStatus&id=' + woId + '&status=' + status, {method:'POST'}).then(function() { location.reload(); }); }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,2 @@
|
||||
PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field"
|
||||
PLG_SYSTEM_MOKOSUITEFIELD_DESC="MokoSuite Field Service system plugin - database schema and helpers."
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field"
|
||||
PLG_SYSTEM_MOKOSUITEFIELD_DESC="MokoSuite Field Service system plugin."
|
||||
@@ -0,0 +1,332 @@
|
||||
--
|
||||
-- MokoSuite Field Service Tables
|
||||
--
|
||||
|
||||
-- ============================================================
|
||||
-- Technicians — extends CRM contacts / HRM employees
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_technicians` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`contact_id` INT NOT NULL COMMENT 'FK to #__contact_details',
|
||||
`employee_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to HRM employees if installed',
|
||||
`tech_number` VARCHAR(20) NOT NULL DEFAULT '',
|
||||
`status` ENUM('available','dispatched','en_route','on_site','off_duty','on_leave') NOT NULL DEFAULT 'available',
|
||||
`trade` ENUM('general','plumbing','electrical','hvac','appliance','carpentry','painting','roofing','landscaping','pest_control','locksmith','multi_trade') NOT NULL DEFAULT 'general',
|
||||
`license_number` VARCHAR(100) DEFAULT NULL COMMENT 'Trade license (e.g., master plumber)',
|
||||
`license_expiry` DATE DEFAULT NULL,
|
||||
`certifications` JSON DEFAULT NULL COMMENT '["EPA 608","backflow certified","journeyman electrician"]',
|
||||
`skills` JSON DEFAULT NULL,
|
||||
`hourly_rate` DECIMAL(10,2) DEFAULT NULL,
|
||||
`overtime_rate` DECIMAL(10,2) DEFAULT NULL,
|
||||
`max_daily_jobs` INT UNSIGNED NOT NULL DEFAULT 8,
|
||||
`service_radius_miles` INT UNSIGNED NOT NULL DEFAULT 30,
|
||||
`home_zip` VARCHAR(10) DEFAULT NULL,
|
||||
`home_lat` DECIMAL(10,7) DEFAULT NULL,
|
||||
`home_lng` DECIMAL(10,7) DEFAULT NULL,
|
||||
`current_lat` DECIMAL(10,7) DEFAULT NULL,
|
||||
`current_lng` DECIMAL(10,7) DEFAULT NULL,
|
||||
`last_location_update` DATETIME DEFAULT NULL,
|
||||
`vehicle_id` INT UNSIGNED DEFAULT NULL,
|
||||
`notes` TEXT,
|
||||
`created` DATETIME NOT NULL,
|
||||
`modified` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_contact` (`contact_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_trade` (`trade`),
|
||||
KEY `idx_vehicle` (`vehicle_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Service Locations — customer property/site records
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_locations` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`contact_id` INT NOT NULL COMMENT 'FK to CRM contact (property owner)',
|
||||
`name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'e.g., "Main Office", "123 Oak St Residence"',
|
||||
`address` TEXT NOT NULL,
|
||||
`city` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
`state` VARCHAR(50) NOT NULL DEFAULT '',
|
||||
`zip` VARCHAR(20) NOT NULL DEFAULT '',
|
||||
`latitude` DECIMAL(10,7) DEFAULT NULL,
|
||||
`longitude` DECIMAL(10,7) DEFAULT NULL,
|
||||
`property_type` ENUM('residential','commercial','industrial','multi_family','government','other') NOT NULL DEFAULT 'residential',
|
||||
`access_notes` TEXT COMMENT 'Gate codes, parking, pet warnings, key location',
|
||||
`equipment_notes` TEXT COMMENT 'Known equipment at this location',
|
||||
`service_history_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`last_service_date` DATE DEFAULT NULL,
|
||||
`created` DATETIME NOT NULL,
|
||||
`modified` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_contact` (`contact_id`),
|
||||
KEY `idx_zip` (`zip`),
|
||||
KEY `idx_type` (`property_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Work Orders — the core job record
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_work_orders` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`wo_number` VARCHAR(30) NOT NULL DEFAULT '',
|
||||
`contact_id` INT NOT NULL COMMENT 'Customer',
|
||||
`location_id` INT UNSIGNED DEFAULT NULL,
|
||||
`technician_id` INT UNSIGNED DEFAULT NULL,
|
||||
`trade` ENUM('general','plumbing','electrical','hvac','appliance','carpentry','painting','roofing','landscaping','pest_control','locksmith','multi_trade') NOT NULL DEFAULT 'general',
|
||||
`priority` ENUM('emergency','urgent','high','normal','low','scheduled') NOT NULL DEFAULT 'normal',
|
||||
`status` ENUM('new','dispatched','en_route','on_site','in_progress','parts_needed','on_hold','completed','invoiced','cancelled') NOT NULL DEFAULT 'new',
|
||||
`category` VARCHAR(100) DEFAULT NULL COMMENT 'e.g., "water heater", "panel upgrade", "AC repair"',
|
||||
`description` TEXT NOT NULL,
|
||||
`customer_po` VARCHAR(100) DEFAULT NULL COMMENT 'Customer PO number',
|
||||
`scheduled_date` DATE DEFAULT NULL,
|
||||
`scheduled_time_start` TIME DEFAULT NULL,
|
||||
`scheduled_time_end` TIME DEFAULT NULL,
|
||||
`actual_arrival` DATETIME DEFAULT NULL,
|
||||
`actual_departure` DATETIME DEFAULT NULL,
|
||||
`work_performed` TEXT,
|
||||
`diagnosis` TEXT,
|
||||
`parts_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
`labor_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
`tax` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
`total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
`payment_status` ENUM('unpaid','partial','paid','waived') NOT NULL DEFAULT 'unpaid',
|
||||
`payment_method` VARCHAR(50) DEFAULT NULL,
|
||||
`customer_signature` TEXT COMMENT 'Base64 signature data',
|
||||
`customer_signed_at` DATETIME DEFAULT NULL,
|
||||
`service_agreement_id` INT UNSIGNED DEFAULT NULL,
|
||||
`invoice_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM invoices',
|
||||
`deal_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM deals',
|
||||
`source` ENUM('phone','website','walk_in','referral','service_agreement','emergency','recurring') NOT NULL DEFAULT 'phone',
|
||||
`created_by` INT NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL,
|
||||
`modified` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_wo_number` (`wo_number`),
|
||||
KEY `idx_contact` (`contact_id`),
|
||||
KEY `idx_location` (`location_id`),
|
||||
KEY `idx_tech` (`technician_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_priority` (`priority`),
|
||||
KEY `idx_trade` (`trade`),
|
||||
KEY `idx_scheduled` (`scheduled_date`),
|
||||
KEY `idx_agreement` (`service_agreement_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Work Order Line Items — parts and labor
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_wo_items` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`work_order_id` INT UNSIGNED NOT NULL,
|
||||
`item_type` ENUM('labor','part','material','flat_rate','discount','permit','disposal') NOT NULL DEFAULT 'labor',
|
||||
`product_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM products for parts',
|
||||
`description` VARCHAR(500) NOT NULL,
|
||||
`quantity` DECIMAL(10,2) NOT NULL DEFAULT 1.00,
|
||||
`unit_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
`line_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
`taxable` TINYINT NOT NULL DEFAULT 1,
|
||||
`from_truck_stock` TINYINT NOT NULL DEFAULT 0 COMMENT 'Taken from tech truck inventory',
|
||||
`sort_order` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_wo` (`work_order_id`),
|
||||
KEY `idx_type` (`item_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Work Order Photos — before/after, damage, equipment
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_wo_photos` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`work_order_id` INT UNSIGNED NOT NULL,
|
||||
`file_path` VARCHAR(500) NOT NULL,
|
||||
`thumbnail_path` VARCHAR(500) DEFAULT NULL,
|
||||
`photo_type` ENUM('before','during','after','damage','equipment','permit','other') NOT NULL DEFAULT 'other',
|
||||
`caption` VARCHAR(500) DEFAULT NULL,
|
||||
`latitude` DECIMAL(10,7) DEFAULT NULL,
|
||||
`longitude` DECIMAL(10,7) DEFAULT NULL,
|
||||
`taken_at` DATETIME DEFAULT NULL,
|
||||
`uploaded_by` INT NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_wo` (`work_order_id`),
|
||||
KEY `idx_type` (`photo_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Service Agreements — recurring maintenance contracts
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_agreements` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`contact_id` INT NOT NULL,
|
||||
`location_id` INT UNSIGNED DEFAULT NULL,
|
||||
`agreement_number` VARCHAR(30) NOT NULL DEFAULT '',
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`trade` ENUM('general','plumbing','electrical','hvac','appliance','multi_trade') NOT NULL DEFAULT 'general',
|
||||
`agreement_type` ENUM('preventive','full_service','parts_only','labor_only','priority_response') NOT NULL DEFAULT 'preventive',
|
||||
`visits_per_year` INT UNSIGNED NOT NULL DEFAULT 2,
|
||||
`visits_used` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`annual_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
`billing_frequency` ENUM('monthly','quarterly','semi_annual','annual') NOT NULL DEFAULT 'annual',
|
||||
`start_date` DATE NOT NULL,
|
||||
`end_date` DATE DEFAULT NULL,
|
||||
`auto_renew` TINYINT NOT NULL DEFAULT 1,
|
||||
`sla_response_hours` INT UNSIGNED DEFAULT NULL COMMENT 'Guaranteed response time',
|
||||
`parts_discount_pct` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
`labor_discount_pct` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
`status` ENUM('active','expired','cancelled','pending_renewal') NOT NULL DEFAULT 'active',
|
||||
`equipment_covered` JSON DEFAULT NULL COMMENT 'List of equipment IDs covered',
|
||||
`notes` TEXT,
|
||||
`created` DATETIME NOT NULL,
|
||||
`modified` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_number` (`agreement_number`),
|
||||
KEY `idx_contact` (`contact_id`),
|
||||
KEY `idx_location` (`location_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_end` (`end_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Equipment — customer equipment tracked for service history
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_equipment` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`location_id` INT UNSIGNED NOT NULL,
|
||||
`contact_id` INT NOT NULL,
|
||||
`equipment_type` ENUM('water_heater','furnace','ac_unit','heat_pump','boiler','electrical_panel','generator','sump_pump','water_softener','tankless_heater','mini_split','rooftop_unit','compressor','chiller','other') NOT NULL DEFAULT 'other',
|
||||
`make` VARCHAR(100) DEFAULT NULL,
|
||||
`model` VARCHAR(100) DEFAULT NULL,
|
||||
`serial_number` VARCHAR(100) DEFAULT NULL,
|
||||
`install_date` DATE DEFAULT NULL,
|
||||
`warranty_expiry` DATE DEFAULT NULL,
|
||||
`last_service_date` DATE DEFAULT NULL,
|
||||
`next_service_date` DATE DEFAULT NULL,
|
||||
`condition` ENUM('excellent','good','fair','poor','needs_replacement') DEFAULT NULL,
|
||||
`location_detail` VARCHAR(255) DEFAULT NULL COMMENT 'e.g., "basement", "roof", "garage"',
|
||||
`refrigerant_type` VARCHAR(20) DEFAULT NULL COMMENT 'HVAC: R-410A, R-22, etc.',
|
||||
`capacity` VARCHAR(50) DEFAULT NULL COMMENT 'e.g., "50 gal", "3 ton", "200 amp"',
|
||||
`fuel_type` VARCHAR(20) DEFAULT NULL COMMENT 'gas, electric, oil, propane',
|
||||
`notes` TEXT,
|
||||
`photo_path` VARCHAR(500) DEFAULT NULL,
|
||||
`qr_code` VARCHAR(100) DEFAULT NULL COMMENT 'QR code on equipment sticker for quick lookup',
|
||||
`created` DATETIME NOT NULL,
|
||||
`modified` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_location` (`location_id`),
|
||||
KEY `idx_contact` (`contact_id`),
|
||||
KEY `idx_type` (`equipment_type`),
|
||||
KEY `idx_serial` (`serial_number`),
|
||||
KEY `idx_qr` (`qr_code`),
|
||||
KEY `idx_next_service` (`next_service_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Vehicles — service fleet tracking
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_vehicles` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`vehicle_number` VARCHAR(20) NOT NULL,
|
||||
`make` VARCHAR(50) DEFAULT NULL,
|
||||
`model` VARCHAR(50) DEFAULT NULL,
|
||||
`year` INT UNSIGNED DEFAULT NULL,
|
||||
`vin` VARCHAR(17) DEFAULT NULL,
|
||||
`license_plate` VARCHAR(20) DEFAULT NULL,
|
||||
`assigned_tech_id` INT UNSIGNED DEFAULT NULL,
|
||||
`status` ENUM('active','maintenance','retired') NOT NULL DEFAULT 'active',
|
||||
`mileage` INT UNSIGNED DEFAULT NULL,
|
||||
`last_inspection` DATE DEFAULT NULL,
|
||||
`next_inspection` DATE DEFAULT NULL,
|
||||
`insurance_expiry` DATE DEFAULT NULL,
|
||||
`gps_device_id` VARCHAR(100) DEFAULT NULL,
|
||||
`notes` TEXT,
|
||||
`created` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tech` (`assigned_tech_id`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Truck Stock — parts inventory per vehicle
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_truck_stock` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`vehicle_id` INT UNSIGNED NOT NULL,
|
||||
`product_id` INT UNSIGNED NOT NULL COMMENT 'FK to CRM products',
|
||||
`quantity` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
`min_quantity` DECIMAL(10,2) NOT NULL DEFAULT 1.00 COMMENT 'Reorder point',
|
||||
`last_restocked` DATE DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_vehicle_product` (`vehicle_id`, `product_id`),
|
||||
KEY `idx_vehicle` (`vehicle_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Dispatch Log — assignment and routing history
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_dispatch_log` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`work_order_id` INT UNSIGNED NOT NULL,
|
||||
`technician_id` INT UNSIGNED NOT NULL,
|
||||
`action` ENUM('assigned','accepted','rejected','en_route','arrived','completed','reassigned','cancelled') NOT NULL,
|
||||
`notes` TEXT,
|
||||
`latitude` DECIMAL(10,7) DEFAULT NULL,
|
||||
`longitude` DECIMAL(10,7) DEFAULT NULL,
|
||||
`created_by` INT NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_wo` (`work_order_id`),
|
||||
KEY `idx_tech` (`technician_id`),
|
||||
KEY `idx_action` (`action`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Estimates — on-site quoting
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_estimates` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`work_order_id` INT UNSIGNED DEFAULT NULL COMMENT 'Generated from a WO inspection',
|
||||
`contact_id` INT NOT NULL,
|
||||
`location_id` INT UNSIGNED DEFAULT NULL,
|
||||
`estimate_number` VARCHAR(30) NOT NULL DEFAULT '',
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT,
|
||||
`parts_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
`labor_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
`tax` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
`total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
`status` ENUM('draft','sent','viewed','accepted','declined','expired','converted') NOT NULL DEFAULT 'draft',
|
||||
`valid_days` INT UNSIGNED NOT NULL DEFAULT 30,
|
||||
`token` VARCHAR(64) DEFAULT NULL COMMENT 'Public view/accept token',
|
||||
`accepted_at` DATETIME DEFAULT NULL,
|
||||
`customer_signature` TEXT,
|
||||
`converted_wo_id` INT UNSIGNED DEFAULT NULL COMMENT 'WO created from accepted estimate',
|
||||
`created_by` INT NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL,
|
||||
`modified` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_number` (`estimate_number`),
|
||||
KEY `idx_contact` (`contact_id`),
|
||||
KEY `idx_wo` (`work_order_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_token` (`token`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Time Entries — tech labor tracking per work order
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitefield_time_entries` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`work_order_id` INT UNSIGNED NOT NULL,
|
||||
`technician_id` INT UNSIGNED NOT NULL,
|
||||
`start_time` DATETIME NOT NULL,
|
||||
`end_time` DATETIME DEFAULT NULL,
|
||||
`hours` DECIMAL(5,2) DEFAULT NULL,
|
||||
`is_overtime` TINYINT NOT NULL DEFAULT 0,
|
||||
`is_travel` TINYINT NOT NULL DEFAULT 0,
|
||||
`rate` DECIMAL(10,2) DEFAULT NULL,
|
||||
`notes` TEXT,
|
||||
`created` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_wo` (`work_order_id`),
|
||||
KEY `idx_tech` (`technician_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,12 @@
|
||||
DROP TABLE IF EXISTS `#__mokosuitefield_time_entries`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitefield_dispatch_log`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitefield_estimates`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitefield_truck_stock`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitefield_vehicles`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitefield_wo_photos`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitefield_wo_items`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitefield_work_orders`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitefield_agreements`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitefield_equipment`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitefield_locations`;
|
||||
DROP TABLE IF EXISTS `#__mokosuitefield_technicians`;
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Dispatch helper — assign techs to jobs based on location, skills, availability.
|
||||
*/
|
||||
class DispatchHelper
|
||||
{
|
||||
/**
|
||||
* Find the best available technician for a work order.
|
||||
* Considers: trade match, distance, current workload, skills.
|
||||
*/
|
||||
public static function findBestTech(string $trade, string $zip, ?array $requiredSkills = null): ?object
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('t.*, cd.name AS tech_name, cd.telephone')
|
||||
->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.status IN (' . $db->quote('dispatched') . ',' . $db->quote('en_route') . ',' . $db->quote('on_site') . ',' . $db->quote('in_progress') . ') AND wo.scheduled_date = CURDATE()) AS today_jobs')
|
||||
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->where($db->quoteName('t.status') . ' = ' . $db->quote('available'))
|
||||
->where('(' . $db->quoteName('t.trade') . ' = ' . $db->quote($trade) . ' OR ' . $db->quoteName('t.trade') . ' = ' . $db->quote('multi_trade') . ')')
|
||||
->having('today_jobs < t.max_daily_jobs')
|
||||
->order('today_jobs ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
$techs = $db->loadObjectList() ?: [];
|
||||
|
||||
if (empty($techs)) return null;
|
||||
|
||||
// If skills required, filter
|
||||
if ($requiredSkills) {
|
||||
$techs = array_filter($techs, function ($t) use ($requiredSkills) {
|
||||
$techSkills = json_decode($t->skills ?? '[]', true) ?: [];
|
||||
foreach ($requiredSkills as $skill) {
|
||||
if (!in_array($skill, $techSkills)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return !empty($techs) ? reset($techs) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a work order to a technician.
|
||||
*/
|
||||
public static function dispatch(int $workOrderId, int $technicianId): bool
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
$db->updateObject('#__mokosuitefield_work_orders', (object) [
|
||||
'id' => $workOrderId,
|
||||
'technician_id' => $technicianId,
|
||||
'status' => 'dispatched',
|
||||
'modified' => $now,
|
||||
], 'id');
|
||||
|
||||
$db->updateObject('#__mokosuitefield_technicians', (object) [
|
||||
'id' => $technicianId,
|
||||
'status' => 'dispatched',
|
||||
], 'id');
|
||||
|
||||
// Log dispatch
|
||||
$db->insertObject('#__mokosuitefield_dispatch_log', (object) [
|
||||
'work_order_id' => $workOrderId,
|
||||
'technician_id' => $technicianId,
|
||||
'action' => 'assigned',
|
||||
'created_by' => Factory::getApplication()->getIdentity()->id,
|
||||
'created' => $now,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's dispatch board — all jobs organized by tech.
|
||||
*/
|
||||
public static function getDispatchBoard(string $date = ''): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$date = $date ?: date('Y-m-d');
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('t.id AS tech_id, cd.name AS tech_name, t.status AS tech_status, t.trade')
|
||||
->select('wo.id AS wo_id, wo.wo_number, wo.status AS wo_status, wo.priority, wo.category')
|
||||
->select('wo.scheduled_time_start, wo.scheduled_time_end')
|
||||
->select('loc.address, loc.city, cust.name AS customer_name')
|
||||
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_work_orders', 'wo') . ' ON wo.technician_id = t.id AND wo.scheduled_date = ' . $db->quote($date) . ' AND wo.status NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ')')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cust') . ' ON cust.id = wo.contact_id')
|
||||
->order('t.id ASC, wo.scheduled_time_start ASC'));
|
||||
|
||||
$rows = $db->loadObjectList() ?: [];
|
||||
|
||||
// Group by tech
|
||||
$board = [];
|
||||
foreach ($rows as $row) {
|
||||
$techId = (int) $row->tech_id;
|
||||
if (!isset($board[$techId])) {
|
||||
$board[$techId] = (object) [
|
||||
'tech_id' => $techId,
|
||||
'tech_name' => $row->tech_name,
|
||||
'status' => $row->tech_status,
|
||||
'trade' => $row->trade,
|
||||
'jobs' => [],
|
||||
];
|
||||
}
|
||||
if ($row->wo_id) {
|
||||
$board[$techId]->jobs[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($board);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unassigned work orders.
|
||||
*/
|
||||
public static function getUnassigned(): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('wo.*, cd.name AS customer_name, loc.address, loc.city, loc.zip')
|
||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
|
||||
->where($db->quoteName('wo.technician_id') . ' IS NULL')
|
||||
->where($db->quoteName('wo.status') . ' = ' . $db->quote('new'))
|
||||
->order('FIELD(wo.priority, ' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ',' . $db->quote('low') . ',' . $db->quote('scheduled') . ') ASC, wo.created ASC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Equipment tracking — customer equipment with service history.
|
||||
*/
|
||||
class EquipmentHelper
|
||||
{
|
||||
public static function getLocationEquipment(int $locationId): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('*')
|
||||
->from('#__mokosuitefield_equipment')
|
||||
->where('location_id = ' . $locationId)
|
||||
->order('equipment_type ASC, make ASC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
public static function getByQrCode(string $qrCode): ?object
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)->select('e.*, loc.address, loc.city, cd.name AS owner_name')
|
||||
->from($db->quoteName('#__mokosuitefield_equipment', 'e'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
|
||||
->where($db->quoteName('e.qr_code') . ' = ' . $db->quote($qrCode)));
|
||||
|
||||
return $db->loadObject() ?: null;
|
||||
}
|
||||
|
||||
public static function getDueForService(int $daysAhead = 30): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('e.*, loc.address, loc.city, cd.name AS owner_name')
|
||||
->from($db->quoteName('#__mokosuitefield_equipment', 'e'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
|
||||
->where($db->quoteName('e.next_service_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)')
|
||||
->order('e.next_service_date ASC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
public static function getWarrantyExpiring(int $daysAhead = 90): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('e.*, loc.address, cd.name AS owner_name')
|
||||
->from($db->quoteName('#__mokosuitefield_equipment', 'e'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
|
||||
->where($db->quoteName('e.warranty_expiry') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)')
|
||||
->order('e.warranty_expiry ASC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
public static function recordService(int $equipmentId): void
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$db->updateObject('#__mokosuitefield_equipment', (object) [
|
||||
'id' => $equipmentId, 'last_service_date' => date('Y-m-d'), 'modified' => Factory::getDate()->toSql(),
|
||||
], 'id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
class EstimateHelper
|
||||
{
|
||||
public static function create(int $contactId, string $title, array $data = []): int
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$now = Factory::getDate()->toSql();
|
||||
$seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuitefield_estimates'))->loadResult() + 1;
|
||||
|
||||
$estimate = (object) [
|
||||
'estimate_number' => 'EST-' . date('Ymd') . '-' . str_pad($seq, 4, '0', STR_PAD_LEFT),
|
||||
'contact_id' => $contactId,
|
||||
'location_id' => (int) ($data['location_id'] ?? 0) ?: null,
|
||||
'work_order_id' => (int) ($data['work_order_id'] ?? 0) ?: null,
|
||||
'title' => $title,
|
||||
'description' => $data['description'] ?? '',
|
||||
'total' => (float) ($data['total'] ?? 0),
|
||||
'status' => 'draft',
|
||||
'valid_days' => (int) ($data['valid_days'] ?? 30),
|
||||
'token' => bin2hex(random_bytes(32)),
|
||||
'created_by' => Factory::getApplication()->getIdentity()->id,
|
||||
'created' => $now,
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuitefield_estimates', $estimate, 'id');
|
||||
return (int) $estimate->id;
|
||||
}
|
||||
|
||||
public static function send(int $estimateId): bool
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('e.*, cd.name AS customer_name, cd.email_to')
|
||||
->from($db->quoteName('#__mokosuitefield_estimates', 'e'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
|
||||
->where('e.id = ' . $estimateId));
|
||||
$est = $db->loadObject();
|
||||
|
||||
if (!$est || !$est->email_to) return false;
|
||||
|
||||
$url = Uri::root() . 'index.php?option=com_mokosuitefield&view=estimate&token=' . $est->token;
|
||||
$mailer = Factory::getMailer();
|
||||
$mailer->addRecipient($est->email_to, $est->customer_name);
|
||||
$mailer->setSubject('Estimate ' . $est->estimate_number);
|
||||
$mailer->setBody("Estimate ready for review:\n{$url}\n\nTotal: \$" . number_format((float) $est->total, 2));
|
||||
$result = $mailer->Send();
|
||||
|
||||
if ($result) {
|
||||
$db->updateObject('#__mokosuitefield_estimates', (object) ['id' => $estimateId, 'status' => 'sent'], 'id');
|
||||
}
|
||||
return (bool) $result;
|
||||
}
|
||||
|
||||
public static function accept(string $token, ?string $signature = null): ?int
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuitefield_estimates')
|
||||
->where($db->quoteName('token') . ' = ' . $db->quote($token))
|
||||
->where($db->quoteName('status') . ' IN (' . $db->quote('sent') . ',' . $db->quote('viewed') . ')'));
|
||||
$est = $db->loadObject();
|
||||
if (!$est) return null;
|
||||
|
||||
$db->updateObject('#__mokosuitefield_estimates', (object) [
|
||||
'id' => $est->id, 'status' => 'accepted', 'accepted_at' => Factory::getDate()->toSql(),
|
||||
'customer_signature' => $signature,
|
||||
], 'id');
|
||||
|
||||
$woId = WorkOrderHelper::create((int) $est->contact_id, 'general', $est->title, [
|
||||
'location_id' => $est->location_id, 'source' => 'website',
|
||||
]);
|
||||
|
||||
$db->updateObject('#__mokosuitefield_estimates', (object) ['id' => $est->id, 'status' => 'converted', 'converted_wo_id' => $woId], 'id');
|
||||
return $woId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Route optimization helper — build daily routes for technicians, reorder stops, calculate drive time.
|
||||
*/
|
||||
class RouteHelper
|
||||
{
|
||||
/**
|
||||
* Get today's route for a technician (work orders sorted by scheduled time).
|
||||
*/
|
||||
public static function getTechRoute(int $techId, string $date = ''): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$date = $date ?: date('Y-m-d');
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('wo.id, wo.wo_number, wo.priority, wo.status, wo.trade')
|
||||
->select('wo.scheduled_date, wo.scheduled_time, wo.estimated_duration')
|
||||
->select('wo.route_order')
|
||||
->select('l.name AS location_name, l.address, l.city, l.state, l.zip')
|
||||
->select('l.latitude, l.longitude')
|
||||
->select('cd.name AS customer_name, cd.telephone AS customer_phone')
|
||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'l') . ' ON l.id = wo.location_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
|
||||
->where($db->quoteName('wo.technician_id') . ' = ' . (int) $techId)
|
||||
->where($db->quoteName('wo.scheduled_date') . ' = ' . $db->quote($date))
|
||||
->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('cancelled') . ',' . $db->quote('completed') . ')')
|
||||
->order('wo.route_order ASC, wo.scheduled_time ASC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-assign route order based on geographic proximity (nearest-neighbor heuristic).
|
||||
* Starts from the tech's home base or first WO location.
|
||||
*/
|
||||
public static function optimizeRoute(int $techId, string $date = ''): array
|
||||
{
|
||||
$stops = self::getTechRoute($techId, $date);
|
||||
if (count($stops) <= 1) return $stops;
|
||||
|
||||
// Get tech home location
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('home_latitude, home_longitude')
|
||||
->from('#__mokosuitefield_technicians')
|
||||
->where('id = ' . (int) $techId));
|
||||
$tech = $db->loadObject();
|
||||
|
||||
$currentLat = (float) ($tech->home_latitude ?? 0);
|
||||
$currentLng = (float) ($tech->home_longitude ?? 0);
|
||||
|
||||
// Nearest-neighbor sort
|
||||
$ordered = [];
|
||||
$remaining = $stops;
|
||||
|
||||
while (!empty($remaining)) {
|
||||
$bestIdx = 0;
|
||||
$bestDist = PHP_FLOAT_MAX;
|
||||
|
||||
foreach ($remaining as $idx => $stop) {
|
||||
$lat = (float) ($stop->latitude ?? 0);
|
||||
$lng = (float) ($stop->longitude ?? 0);
|
||||
|
||||
if ($lat === 0.0 && $lng === 0.0) {
|
||||
// No coordinates — keep in original position
|
||||
$dist = PHP_FLOAT_MAX - 1;
|
||||
} else {
|
||||
$dist = self::haversine($currentLat, $currentLng, $lat, $lng);
|
||||
}
|
||||
|
||||
if ($dist < $bestDist) {
|
||||
$bestDist = $dist;
|
||||
$bestIdx = $idx;
|
||||
}
|
||||
}
|
||||
|
||||
$next = $remaining[$bestIdx];
|
||||
$ordered[] = $next;
|
||||
$currentLat = (float) ($next->latitude ?? $currentLat);
|
||||
$currentLng = (float) ($next->longitude ?? $currentLng);
|
||||
array_splice($remaining, $bestIdx, 1);
|
||||
$remaining = array_values($remaining);
|
||||
}
|
||||
|
||||
// Save route order
|
||||
foreach ($ordered as $i => $stop) {
|
||||
$update = (object) [
|
||||
'id' => $stop->id,
|
||||
'route_order' => $i + 1,
|
||||
];
|
||||
$db->updateObject('#__mokosuitefield_work_orders', $update, 'id');
|
||||
$ordered[$i]->route_order = $i + 1;
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually reorder a stop within a tech's route.
|
||||
*/
|
||||
public static function reorderStop(int $woId, int $newPosition): bool
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('technician_id, scheduled_date')
|
||||
->from('#__mokosuitefield_work_orders')
|
||||
->where('id = ' . (int) $woId));
|
||||
$wo = $db->loadObject();
|
||||
if (!$wo) return false;
|
||||
|
||||
$stops = self::getTechRoute((int) $wo->technician_id, $wo->scheduled_date);
|
||||
|
||||
// Find and remove the target WO
|
||||
$target = null;
|
||||
$filtered = [];
|
||||
foreach ($stops as $stop) {
|
||||
if ((int) $stop->id === $woId) {
|
||||
$target = $stop;
|
||||
} else {
|
||||
$filtered[] = $stop;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$target) return false;
|
||||
|
||||
// Insert at new position
|
||||
$newPosition = max(1, min($newPosition, count($filtered) + 1));
|
||||
array_splice($filtered, $newPosition - 1, 0, [$target]);
|
||||
|
||||
// Save new order
|
||||
foreach ($filtered as $i => $stop) {
|
||||
$update = (object) [
|
||||
'id' => $stop->id,
|
||||
'route_order' => $i + 1,
|
||||
];
|
||||
$db->updateObject('#__mokosuitefield_work_orders', $update, 'id');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate total drive time and distance for a route (using straight-line approximation).
|
||||
*/
|
||||
public static function estimateRouteMetrics(int $techId, string $date = ''): object
|
||||
{
|
||||
$stops = self::getTechRoute($techId, $date);
|
||||
|
||||
$totalDistance = 0.0;
|
||||
$totalJobTime = 0;
|
||||
$legs = [];
|
||||
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('home_latitude, home_longitude')
|
||||
->from('#__mokosuitefield_technicians')
|
||||
->where('id = ' . (int) $techId));
|
||||
$tech = $db->loadObject();
|
||||
|
||||
$prevLat = (float) ($tech->home_latitude ?? 0);
|
||||
$prevLng = (float) ($tech->home_longitude ?? 0);
|
||||
|
||||
foreach ($stops as $stop) {
|
||||
$lat = (float) ($stop->latitude ?? 0);
|
||||
$lng = (float) ($stop->longitude ?? 0);
|
||||
$dist = 0;
|
||||
|
||||
if ($lat && $lng && ($prevLat || $prevLng)) {
|
||||
$dist = self::haversine($prevLat, $prevLng, $lat, $lng);
|
||||
}
|
||||
|
||||
$totalDistance += $dist;
|
||||
$totalJobTime += (int) ($stop->estimated_duration ?? 60);
|
||||
|
||||
$legs[] = (object) [
|
||||
'wo_id' => $stop->id,
|
||||
'location' => $stop->location_name ?? $stop->address,
|
||||
'distance' => round($dist, 1),
|
||||
'drive_min'=> round($dist / 0.5, 0), // ~30 mph avg in service areas
|
||||
];
|
||||
|
||||
$prevLat = $lat ?: $prevLat;
|
||||
$prevLng = $lng ?: $prevLng;
|
||||
}
|
||||
|
||||
$totalDriveMin = $totalDistance > 0 ? round($totalDistance / 0.5) : 0;
|
||||
|
||||
return (object) [
|
||||
'stop_count' => count($stops),
|
||||
'total_distance' => round($totalDistance, 1),
|
||||
'total_drive_min'=> $totalDriveMin,
|
||||
'total_job_min' => $totalJobTime,
|
||||
'total_day_min' => $totalDriveMin + $totalJobTime,
|
||||
'legs' => $legs,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Haversine distance in miles.
|
||||
*/
|
||||
private static function haversine(float $lat1, float $lon1, float $lat2, float $lon2): float
|
||||
{
|
||||
$R = 3959; // Earth radius in miles
|
||||
$dLat = deg2rad($lat2 - $lat1);
|
||||
$dLon = deg2rad($lon2 - $lon1);
|
||||
$a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon / 2) ** 2;
|
||||
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||
return $R * $c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a GPS breadcrumb for a tech (called from mobile app).
|
||||
*/
|
||||
public static function logGpsBreadcrumb(int $techId, float $lat, float $lng, ?int $woId = null): void
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$crumb = (object) [
|
||||
'technician_id' => $techId,
|
||||
'latitude' => $lat,
|
||||
'longitude' => $lng,
|
||||
'wo_id' => $woId,
|
||||
'recorded_at' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuitefield_dispatch_log', $crumb);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Service agreement helper — recurring maintenance contracts with SLA.
|
||||
*/
|
||||
class ServiceAgreementHelper
|
||||
{
|
||||
public static function getActiveAgreements(int $contactId = 0): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('a.*, cd.name AS customer_name, loc.address')
|
||||
->from($db->quoteName('#__mokosuitefield_agreements', 'a'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = a.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = a.location_id')
|
||||
->where($db->quoteName('a.status') . ' = ' . $db->quote('active'))
|
||||
->order('a.end_date ASC');
|
||||
|
||||
if ($contactId) $query->where('a.contact_id = ' . $contactId);
|
||||
|
||||
$db->setQuery($query);
|
||||
$agreements = $db->loadObjectList() ?: [];
|
||||
|
||||
foreach ($agreements as &$a) {
|
||||
$a->visits_remaining = max(0, (int) $a->visits_per_year - (int) $a->visits_used);
|
||||
$a->days_until_expiry = $a->end_date ? max(0, round((strtotime($a->end_date) - time()) / 86400)) : null;
|
||||
}
|
||||
|
||||
return $agreements;
|
||||
}
|
||||
|
||||
public static function getExpiring(int $daysAhead = 30): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('a.*, cd.name AS customer_name, cd.email_to')
|
||||
->from($db->quoteName('#__mokosuitefield_agreements', 'a'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = a.contact_id')
|
||||
->where($db->quoteName('a.status') . ' = ' . $db->quote('active'))
|
||||
->where($db->quoteName('a.end_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)')
|
||||
->order('a.end_date ASC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
public static function getRevenueSummary(): object
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('COUNT(*) AS active_agreements')
|
||||
->select('COALESCE(SUM(annual_amount), 0) AS annual_recurring')
|
||||
->select('COALESCE(SUM(annual_amount / 12), 0) AS monthly_recurring')
|
||||
->from('#__mokosuitefield_agreements')
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('active')));
|
||||
|
||||
return $db->loadObject() ?: (object) ['active_agreements' => 0, 'annual_recurring' => 0, 'monthly_recurring' => 0];
|
||||
}
|
||||
|
||||
public static function recordVisit(int $agreementId, int $workOrderId): void
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->update('#__mokosuitefield_agreements')
|
||||
->set('visits_used = visits_used + 1')
|
||||
->where('id = ' . $agreementId));
|
||||
$db->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Truck stock management — per-vehicle parts inventory.
|
||||
*/
|
||||
class TruckStockHelper
|
||||
{
|
||||
public static function getVehicleInventory(int $vehicleId): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('ts.*, p.name AS part_name, p.sku, p.cost_price')
|
||||
->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts'))
|
||||
->join('INNER', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id')
|
||||
->where('ts.vehicle_id = ' . $vehicleId)
|
||||
->order('p.name ASC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
public static function getLowStock(): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('ts.*, p.name AS part_name, p.sku, v.vehicle_number')
|
||||
->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts'))
|
||||
->join('INNER', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id')
|
||||
->join('INNER', $db->quoteName('#__mokosuitefield_vehicles', 'v') . ' ON v.id = ts.vehicle_id')
|
||||
->where('ts.quantity <= ts.min_quantity')
|
||||
->order('ts.quantity ASC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
public static function usePart(int $vehicleId, int $productId, float $qty = 1): bool
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->update('#__mokosuitefield_truck_stock')
|
||||
->set('quantity = quantity - ' . (float) $qty)
|
||||
->where('vehicle_id = ' . $vehicleId)
|
||||
->where('product_id = ' . $productId));
|
||||
$db->execute();
|
||||
|
||||
return $db->getAffectedRows() > 0;
|
||||
}
|
||||
|
||||
public static function restock(int $vehicleId, int $productId, float $qty): void
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery("INSERT INTO #__mokosuitefield_truck_stock (vehicle_id, product_id, quantity, last_restocked) VALUES ({$vehicleId}, {$productId}, {$qty}, CURDATE()) ON DUPLICATE KEY UPDATE quantity = quantity + {$qty}, last_restocked = CURDATE()");
|
||||
$db->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Fleet/vehicle management.
|
||||
*/
|
||||
class VehicleHelper
|
||||
{
|
||||
public static function getFleet(): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('v.*, cd.name AS assigned_tech_name')
|
||||
->select('(SELECT COUNT(*) FROM #__mokosuitefield_truck_stock ts WHERE ts.vehicle_id = v.id AND ts.quantity <= ts.min_quantity) AS low_stock_items')
|
||||
->from($db->quoteName('#__mokosuitefield_vehicles', 'v'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.assigned_tech_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->order('v.vehicle_number ASC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
public static function getInspectionsDue(int $daysAhead = 30): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('v.*, cd.name AS tech_name')
|
||||
->from($db->quoteName('#__mokosuitefield_vehicles', 'v'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.assigned_tech_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->where($db->quoteName('v.status') . ' = ' . $db->quote('active'))
|
||||
->where($db->quoteName('v.next_inspection') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)')
|
||||
->order('v.next_inspection ASC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Work order lifecycle helper — create, update status, complete, invoice.
|
||||
*/
|
||||
class WorkOrderHelper
|
||||
{
|
||||
public static function create(int $contactId, string $trade, string $description, array $data = []): int
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
// Read prefix from component config (config.xml)
|
||||
$params = Factory::getApplication()->getParams('com_mokosuitefield');
|
||||
$woPrefix = $params->get('wo_prefix', 'WO');
|
||||
$defaultTrade = $params->get('default_trade', 'general');
|
||||
|
||||
if (!$trade) $trade = $defaultTrade;
|
||||
|
||||
$seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuitefield_work_orders'))->loadResult() + 1;
|
||||
|
||||
$wo = (object) [
|
||||
'wo_number' => $woPrefix . '-' . date('Ymd') . '-' . str_pad($seq, 4, '0', STR_PAD_LEFT),
|
||||
'contact_id' => $contactId,
|
||||
'location_id' => (int) ($data['location_id'] ?? 0) ?: null,
|
||||
'trade' => $trade,
|
||||
'priority' => $data['priority'] ?? 'normal',
|
||||
'status' => 'new',
|
||||
'category' => $data['category'] ?? null,
|
||||
'description' => $description,
|
||||
'customer_po' => $data['customer_po'] ?? null,
|
||||
'scheduled_date' => $data['scheduled_date'] ?? null,
|
||||
'scheduled_time_start' => $data['time_start'] ?? null,
|
||||
'scheduled_time_end' => $data['time_end'] ?? null,
|
||||
'service_agreement_id' => (int) ($data['agreement_id'] ?? 0) ?: null,
|
||||
'source' => $data['source'] ?? 'phone',
|
||||
'created_by' => Factory::getApplication()->getIdentity()->id,
|
||||
'created' => $now,
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuitefield_work_orders', $wo, 'id');
|
||||
|
||||
return (int) $wo->id;
|
||||
}
|
||||
|
||||
public static function updateStatus(int $woId, string $status, ?float $lat = null, ?float $lng = null): void
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
$update = (object) ['id' => $woId, 'status' => $status, 'modified' => $now];
|
||||
|
||||
if ($status === 'on_site') $update->actual_arrival = $now;
|
||||
if ($status === 'completed') $update->actual_departure = $now;
|
||||
|
||||
$db->updateObject('#__mokosuitefield_work_orders', $update, 'id');
|
||||
|
||||
// Get tech ID for dispatch log
|
||||
$db->setQuery($db->getQuery(true)->select('technician_id')->from('#__mokosuitefield_work_orders')->where('id = ' . $woId));
|
||||
$techId = (int) $db->loadResult();
|
||||
|
||||
if ($techId) {
|
||||
$action = match ($status) {
|
||||
'en_route' => 'en_route',
|
||||
'on_site' => 'arrived',
|
||||
'completed' => 'completed',
|
||||
'cancelled' => 'cancelled',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($action) {
|
||||
$db->insertObject('#__mokosuitefield_dispatch_log', (object) [
|
||||
'work_order_id' => $woId,
|
||||
'technician_id' => $techId,
|
||||
'action' => $action,
|
||||
'latitude' => $lat,
|
||||
'longitude' => $lng,
|
||||
'created_by' => Factory::getApplication()->getIdentity()->id,
|
||||
'created' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
// Update tech status
|
||||
if ($status === 'completed' || $status === 'cancelled') {
|
||||
$db->updateObject('#__mokosuitefield_technicians', (object) ['id' => $techId, 'status' => 'available'], 'id');
|
||||
} elseif ($status === 'en_route') {
|
||||
$db->updateObject('#__mokosuitefield_technicians', (object) [
|
||||
'id' => $techId, 'status' => 'en_route', 'current_lat' => $lat, 'current_lng' => $lng, 'last_location_update' => $now,
|
||||
], 'id');
|
||||
} elseif ($status === 'on_site') {
|
||||
$db->updateObject('#__mokosuitefield_technicians', (object) ['id' => $techId, 'status' => 'on_site'], 'id');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function complete(int $woId, string $workPerformed, ?string $signature = null): void
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
// Calculate totals from line items
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('COALESCE(SUM(CASE WHEN item_type = ' . $db->quote('labor') . ' THEN line_total ELSE 0 END), 0) AS labor')
|
||||
->select('COALESCE(SUM(CASE WHEN item_type != ' . $db->quote('labor') . ' THEN line_total ELSE 0 END), 0) AS parts')
|
||||
->from('#__mokosuitefield_wo_items')
|
||||
->where('work_order_id = ' . $woId));
|
||||
$totals = $db->loadObject();
|
||||
|
||||
$laborTotal = (float) ($totals->labor ?? 0);
|
||||
$partsTotal = (float) ($totals->parts ?? 0);
|
||||
$total = $laborTotal + $partsTotal;
|
||||
|
||||
$db->updateObject('#__mokosuitefield_work_orders', (object) [
|
||||
'id' => $woId,
|
||||
'status' => 'completed',
|
||||
'work_performed' => $workPerformed,
|
||||
'parts_total' => $partsTotal,
|
||||
'labor_total' => $laborTotal,
|
||||
'total' => $total,
|
||||
'customer_signature' => $signature,
|
||||
'customer_signed_at' => $signature ? $now : null,
|
||||
'actual_departure' => $now,
|
||||
'modified' => $now,
|
||||
], 'id');
|
||||
|
||||
// Update location service history
|
||||
$db->setQuery($db->getQuery(true)->select('location_id')->from('#__mokosuitefield_work_orders')->where('id = ' . $woId));
|
||||
$locId = (int) $db->loadResult();
|
||||
if ($locId) {
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->update('#__mokosuitefield_locations')
|
||||
->set('service_history_count = service_history_count + 1')
|
||||
->set('last_service_date = ' . $db->quote(date('Y-m-d')))
|
||||
->where('id = ' . $locId));
|
||||
$db->execute();
|
||||
}
|
||||
}
|
||||
|
||||
public static function getDashboardStats(): object
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('COUNT(*) AS total_today')
|
||||
->select('SUM(CASE WHEN status = ' . $db->quote('new') . ' THEN 1 ELSE 0 END) AS unassigned')
|
||||
->select('SUM(CASE WHEN status IN (' . $db->quote('dispatched') . ',' . $db->quote('en_route') . ') THEN 1 ELSE 0 END) AS en_route')
|
||||
->select('SUM(CASE WHEN status IN (' . $db->quote('on_site') . ',' . $db->quote('in_progress') . ') THEN 1 ELSE 0 END) AS on_site')
|
||||
->select('SUM(CASE WHEN status = ' . $db->quote('completed') . ' THEN 1 ELSE 0 END) AS completed')
|
||||
->select('SUM(CASE WHEN priority IN (' . $db->quote('emergency') . ',' . $db->quote('urgent') . ') THEN 1 ELSE 0 END) AS urgent')
|
||||
->from('#__mokosuitefield_work_orders')
|
||||
->where('scheduled_date = ' . $db->quote($today) . ' OR (scheduled_date IS NULL AND DATE(created) = ' . $db->quote($today) . ')'));
|
||||
|
||||
return $db->loadObject() ?: (object) ['total_today' => 0, 'unassigned' => 0, 'en_route' => 0, 'on_site' => 0, 'completed' => 0, 'urgent' => 0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
namespace Moko\Plugin\Task\MokoSuiteField\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
|
||||
use Joomla\Component\Scheduler\Administrator\Task\Status;
|
||||
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
/**
|
||||
* Field service scheduled tasks: service reminders, agreement renewals,
|
||||
* equipment maintenance alerts, truck stock reorder.
|
||||
*/
|
||||
class FieldAutomation extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
use TaskPluginTrait;
|
||||
|
||||
protected const TASKS_MAP = [
|
||||
'mokosuite.field.service.reminders' => [
|
||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_SERVICE_REMINDERS',
|
||||
'method' => 'sendServiceReminders',
|
||||
],
|
||||
'mokosuite.field.agreement.renewal' => [
|
||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_AGREEMENT_RENEWAL',
|
||||
'method' => 'checkAgreementRenewals',
|
||||
],
|
||||
'mokosuite.field.equipment.maintenance' => [
|
||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_EQUIPMENT_MAINTENANCE',
|
||||
'method' => 'checkEquipmentMaintenance',
|
||||
],
|
||||
'mokosuite.field.truck.reorder' => [
|
||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_TRUCK_REORDER',
|
||||
'method' => 'checkTruckStock',
|
||||
],
|
||||
];
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onExecuteTask' => 'standardRoutineHandler',
|
||||
'onContentPrepareForm' => 'enhanceTaskItemForm',
|
||||
'onTaskOptionsList' => 'advertiseRoutines',
|
||||
];
|
||||
}
|
||||
|
||||
private function sendServiceReminders(ExecuteTaskEvent $event): int
|
||||
{
|
||||
$equipment = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getDueForService(14);
|
||||
|
||||
if (empty($equipment)) return Status::OK;
|
||||
|
||||
$body = "Equipment due for service (next 14 days):\n\n";
|
||||
foreach ($equipment as $e) {
|
||||
$body .= "- {$e->equipment_type} ({$e->make} {$e->model}) at {$e->address}, {$e->owner_name}\n";
|
||||
$body .= " Due: " . date('M j, Y', strtotime($e->next_service_date)) . "\n";
|
||||
}
|
||||
|
||||
$mailer = Factory::getMailer();
|
||||
$mailer->addRecipient(Factory::getApplication()->get('mailfrom'));
|
||||
$mailer->setSubject('Field Service: ' . count($equipment) . ' equipment due for maintenance');
|
||||
$mailer->setBody($body);
|
||||
$mailer->Send();
|
||||
|
||||
Log::add("Field equipment reminders: " . count($equipment) . " due", Log::INFO, 'mokosuite.field');
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
private function checkAgreementRenewals(ExecuteTaskEvent $event): int
|
||||
{
|
||||
$expiring = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getExpiring(30);
|
||||
|
||||
foreach ($expiring as $a) {
|
||||
if (!$a->email_to) continue;
|
||||
|
||||
$mailer = Factory::getMailer();
|
||||
$mailer->addRecipient($a->email_to, $a->customer_name);
|
||||
$mailer->setSubject('Service Agreement Renewal — ' . $a->title);
|
||||
$mailer->setBody(
|
||||
"Hi {$a->customer_name},\n\n"
|
||||
. "Your service agreement \"{$a->title}\" expires on " . date('F j, Y', strtotime($a->end_date)) . ".\n\n"
|
||||
. "Please contact us to renew.\n"
|
||||
);
|
||||
$mailer->Send();
|
||||
}
|
||||
|
||||
Log::add("Field agreement renewals: " . count($expiring) . " expiring", Log::INFO, 'mokosuite.field');
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
private function checkEquipmentMaintenance(ExecuteTaskEvent $event): int
|
||||
{
|
||||
$warranty = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getWarrantyExpiring(90);
|
||||
|
||||
if (!empty($warranty)) {
|
||||
$body = "Equipment warranties expiring (90 days):\n\n";
|
||||
foreach ($warranty as $e) {
|
||||
$body .= "- {$e->make} {$e->model} (SN: {$e->serial_number}) — {$e->owner_name}\n";
|
||||
$body .= " Warranty expires: " . date('M j, Y', strtotime($e->warranty_expiry)) . "\n";
|
||||
}
|
||||
|
||||
$mailer = Factory::getMailer();
|
||||
$mailer->addRecipient(Factory::getApplication()->get('mailfrom'));
|
||||
$mailer->setSubject('Field: ' . count($warranty) . ' equipment warranties expiring');
|
||||
$mailer->setBody($body);
|
||||
$mailer->Send();
|
||||
}
|
||||
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
private function checkTruckStock(ExecuteTaskEvent $event): int
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('ts.*, p.name AS part_name, p.sku, v.vehicle_number')
|
||||
->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts'))
|
||||
->join('INNER', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id')
|
||||
->join('INNER', $db->quoteName('#__mokosuitefield_vehicles', 'v') . ' ON v.id = ts.vehicle_id')
|
||||
->where('ts.quantity <= ts.min_quantity'));
|
||||
$low = $db->loadObjectList() ?: [];
|
||||
|
||||
if (!empty($low)) {
|
||||
$body = "Truck stock below reorder point:\n\n";
|
||||
foreach ($low as $item) {
|
||||
$body .= "- Vehicle {$item->vehicle_number}: {$item->part_name} ({$item->sku}) — {$item->quantity} remaining (min: {$item->min_quantity})\n";
|
||||
}
|
||||
|
||||
$mailer = Factory::getMailer();
|
||||
$mailer->addRecipient(Factory::getApplication()->get('mailfrom'));
|
||||
$mailer->setSubject('Field: ' . count($low) . ' truck stock items need reorder');
|
||||
$mailer->setBody($body);
|
||||
$mailer->Send();
|
||||
}
|
||||
|
||||
Log::add("Field truck stock: " . count($low) . " items low", Log::INFO, 'mokosuite.field');
|
||||
return Status::OK;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace Moko\Plugin\WebServices\MokoSuiteField\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Event\Application\BeforeApiRouteEvent;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
final class MokoSuiteFieldApi extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return ['onBeforeApiRoute' => 'onBeforeApiRoute'];
|
||||
}
|
||||
|
||||
public function onBeforeApiRoute(BeforeApiRouteEvent $event): void
|
||||
{
|
||||
$router = $event->getRouter();
|
||||
$router->createCRUDRoutes('v1/mokosuite/field/workorders', 'fieldworkorders', ['component' => 'com_mokosuitefield']);
|
||||
$router->createCRUDRoutes('v1/mokosuite/field/technicians', 'fieldtechnicians', ['component' => 'com_mokosuitefield']);
|
||||
$router->createCRUDRoutes('v1/mokosuite/field/equipment', 'fieldequipment', ['component' => 'com_mokosuitefield']);
|
||||
$router->createCRUDRoutes('v1/mokosuite/field/agreements', 'fieldagreements', ['component' => 'com_mokosuitefield']);
|
||||
$router->createCRUDRoutes('v1/mokosuite/field/estimates', 'fieldestimates', ['component' => 'com_mokosuitefield']);
|
||||
$router->createCRUDRoutes('v1/mokosuite/field/locations', 'fieldlocations', ['component' => 'com_mokosuitefield']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuite Field</name>
|
||||
<packagename>mokosuitefield</packagename>
|
||||
<version>01.01.00</version>
|
||||
<creationDate>2026-06-12</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<description>MokoSuite Field Service - dispatch, work orders, scheduling, mobile tech. Layer 2 add-on for MokoSuite (requires CRM).</description>
|
||||
<php_minimum>8.3</php_minimum>
|
||||
<dlid prefix="dlid=" suffix=""/>
|
||||
<blockChildUninstall>true</blockChildUninstall>
|
||||
<files folder="packages">
|
||||
<file type="plugin" id="plg_system_mokosuitefield" group="system">plg_system_mokosuitefield.zip</file>
|
||||
<file type="component" id="com_mokosuitefield">com_mokosuitefield.zip</file>
|
||||
<file type="plugin" id="plg_webservices_mokosuitefield" group="webservices">plg_webservices_mokosuitefield.zip</file>
|
||||
</files>
|
||||
<updateservers>
|
||||
<server type="extension" priority="1" name="Package - MokoSuite Field">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteField/updates.xml</server>
|
||||
</updateservers>
|
||||
</extension>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<updates><update>
|
||||
<name>Package - MokoSuite Field</name>
|
||||
<element>pkg_mokosuitefield</element>
|
||||
<type>package</type>
|
||||
<version>01.01.00</version>
|
||||
<targetplatform name="joomla" version="6.[0-9]" />
|
||||
<php_minimum>8.3</php_minimum>
|
||||
</update></updates>
|
||||
Reference in New Issue
Block a user