diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml
new file mode 100644
index 0000000..10f5bf5
--- /dev/null
+++ b/.gitea/workflows/build.yaml
@@ -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
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..1321885
--- /dev/null
+++ b/.gitmodules
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d001f04
--- /dev/null
+++ b/CHANGELOG.md
@@ -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+)
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..37df8bd
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1 @@
+GPL-3.0-or-later
diff --git a/packages/MokoSuiteCRM b/packages/MokoSuiteCRM
new file mode 160000
index 0000000..6c78af1
--- /dev/null
+++ b/packages/MokoSuiteCRM
@@ -0,0 +1 @@
+Subproject commit 6c78af16e6cd0f31f953f90701c403d60d8db766
diff --git a/packages/MokoSuiteClient b/packages/MokoSuiteClient
new file mode 160000
index 0000000..6cd16d9
--- /dev/null
+++ b/packages/MokoSuiteClient
@@ -0,0 +1 @@
+Subproject commit 6cd16d984589fabbfd0b074b0d3308b5e64967f0
diff --git a/source/packages/com_mokosuitefield/admin/access.xml b/source/packages/com_mokosuitefield/admin/access.xml
new file mode 100644
index 0000000..6f4f869
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/access.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/source/packages/com_mokosuitefield/admin/config.xml b/source/packages/com_mokosuitefield/admin/config.xml
new file mode 100644
index 0000000..b17bb28
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/config.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/source/packages/com_mokosuitefield/admin/services/provider.php b/source/packages/com_mokosuitefield/admin/services/provider.php
new file mode 100644
index 0000000..f056f8e
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/services/provider.php
@@ -0,0 +1,18 @@
+set(ComponentInterface::class, function (Container $container) {
+ $component = new MVCComponent($container->get(ComponentDispatcherFactoryInterface::class));
+ $component->setMVCFactory($container->get(MVCFactoryInterface::class));
+ return $component;
+ });
+ }
+};
diff --git a/source/packages/com_mokosuitefield/admin/src/Controller/DisplayController.php b/source/packages/com_mokosuitefield/admin/src/Controller/DisplayController.php
new file mode 100644
index 0000000..0129bef
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/src/Controller/DisplayController.php
@@ -0,0 +1,11 @@
+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() ?: [];
+ }
+}
diff --git a/source/packages/com_mokosuitefield/admin/src/Model/EquipmentModel.php b/source/packages/com_mokosuitefield/admin/src/Model/EquipmentModel.php
new file mode 100644
index 0000000..2f244ad
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/src/Model/EquipmentModel.php
@@ -0,0 +1,29 @@
+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() ?: [];
+ }
+}
diff --git a/source/packages/com_mokosuitefield/admin/src/Model/EstimatesModel.php b/source/packages/com_mokosuitefield/admin/src/Model/EstimatesModel.php
new file mode 100644
index 0000000..7332a46
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/src/Model/EstimatesModel.php
@@ -0,0 +1,28 @@
+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() ?: [];
+ }
+}
diff --git a/source/packages/com_mokosuitefield/admin/src/Model/ServiceAgreementsModel.php b/source/packages/com_mokosuitefield/admin/src/Model/ServiceAgreementsModel.php
new file mode 100644
index 0000000..c01a746
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/src/Model/ServiceAgreementsModel.php
@@ -0,0 +1,39 @@
+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() ?: [];
+ }
+}
diff --git a/source/packages/com_mokosuitefield/admin/src/Model/TechniciansModel.php b/source/packages/com_mokosuitefield/admin/src/Model/TechniciansModel.php
new file mode 100644
index 0000000..6a32578
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/src/Model/TechniciansModel.php
@@ -0,0 +1,27 @@
+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() ?: [];
+ }
+}
diff --git a/source/packages/com_mokosuitefield/admin/src/Model/VehiclesModel.php b/source/packages/com_mokosuitefield/admin/src/Model/VehiclesModel.php
new file mode 100644
index 0000000..0429c3a
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/src/Model/VehiclesModel.php
@@ -0,0 +1,26 @@
+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() ?: [];
+ }
+}
diff --git a/source/packages/com_mokosuitefield/admin/src/Model/WorkOrdersModel.php b/source/packages/com_mokosuitefield/admin/src/Model/WorkOrdersModel.php
new file mode 100644
index 0000000..af03434
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/src/Model/WorkOrdersModel.php
@@ -0,0 +1,62 @@
+getDatabase();
+ $query = $db->getQuery(true)
+ ->select('wo.*, cd.name AS customer_name, loc.address, loc.city, loc.state, loc.zip')
+ ->select('t_cd.name AS tech_name')
+ ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
+ ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
+ ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
+ ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id')
+ ->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id')
+ ->order('wo.scheduled_date DESC, wo.priority DESC');
+
+ if ($status) $query->where($db->quoteName('wo.status') . ' = ' . $db->quote($status));
+ if ($trade) $query->where($db->quoteName('wo.trade') . ' = ' . $db->quote($trade));
+ if ($techId) $query->where('wo.technician_id = ' . $techId);
+ if ($date) $query->where('wo.scheduled_date = ' . $db->quote($date));
+ if ($search) {
+ $query->where('(' . $db->quoteName('wo.wo_number') . ' LIKE ' . $db->quote('%' . $search . '%')
+ . ' OR ' . $db->quoteName('wo.description') . ' LIKE ' . $db->quote('%' . $search . '%')
+ . ' OR ' . $db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%') . ')');
+ }
+
+ $db->setQuery($query, $offset, $limit);
+ return $db->loadObjectList() ?: [];
+ }
+
+ public function getWorkOrder(int $id): ?object
+ {
+ $db = $this->getDatabase();
+ $db->setQuery($db->getQuery(true)
+ ->select('wo.*, cd.name AS customer_name, t_cd.name AS tech_name')
+ ->select('loc.address, loc.city, loc.state, loc.zip, loc.latitude, loc.longitude, loc.name AS location_name')
+ ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
+ ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
+ ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
+ ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id')
+ ->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id')
+ ->where('wo.id = ' . (int) $id));
+ return $db->loadObject();
+ }
+
+ public function getStatusCounts(): object
+ {
+ $db = $this->getDatabase();
+ $db->setQuery($db->getQuery(true)
+ ->select('status, COUNT(*) AS cnt')
+ ->from('#__mokosuitefield_work_orders')
+ ->group('status'));
+ $rows = $db->loadObjectList('status') ?: [];
+ return (object) array_map(fn($r) => (int) $r->cnt, (array) $rows);
+ }
+}
diff --git a/source/packages/com_mokosuitefield/admin/src/View/Dashboard/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Dashboard/HtmlView.php
new file mode 100644
index 0000000..c6b9eaf
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/src/View/Dashboard/HtmlView.php
@@ -0,0 +1,40 @@
+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);
+ }
+}
diff --git a/source/packages/com_mokosuitefield/admin/src/View/Dispatch/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Dispatch/HtmlView.php
new file mode 100644
index 0000000..e59bd25
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/src/View/Dispatch/HtmlView.php
@@ -0,0 +1,28 @@
+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);
+ }
+}
diff --git a/source/packages/com_mokosuitefield/admin/src/View/Equipment/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Equipment/HtmlView.php
new file mode 100644
index 0000000..5188748
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/src/View/Equipment/HtmlView.php
@@ -0,0 +1,34 @@
+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);
+ }
+}
diff --git a/source/packages/com_mokosuitefield/admin/src/View/ServiceAgreements/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/ServiceAgreements/HtmlView.php
new file mode 100644
index 0000000..ecec27f
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/src/View/ServiceAgreements/HtmlView.php
@@ -0,0 +1,23 @@
+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);
+ }
+}
diff --git a/source/packages/com_mokosuitefield/admin/src/View/Technicians/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Technicians/HtmlView.php
new file mode 100644
index 0000000..9f6f903
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/src/View/Technicians/HtmlView.php
@@ -0,0 +1,32 @@
+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);
+ }
+}
diff --git a/source/packages/com_mokosuitefield/admin/src/View/Vehicles/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Vehicles/HtmlView.php
new file mode 100644
index 0000000..1ead5bc
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/src/View/Vehicles/HtmlView.php
@@ -0,0 +1,23 @@
+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);
+ }
+}
diff --git a/source/packages/com_mokosuitefield/admin/src/View/WorkOrders/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/WorkOrders/HtmlView.php
new file mode 100644
index 0000000..06d57c4
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/src/View/WorkOrders/HtmlView.php
@@ -0,0 +1,52 @@
+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);
+ }
+}
diff --git a/source/packages/com_mokosuitefield/admin/tmpl/dashboard/default.php b/source/packages/com_mokosuitefield/admin/tmpl/dashboard/default.php
new file mode 100644
index 0000000..ecd06c8
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/tmpl/dashboard/default.php
@@ -0,0 +1,33 @@
+stats;
+$board = $this->dispatchBoard;
+$unassigned = $this->unassigned;
+$urgent = $this->urgent;
+?>
+
+
+
+
+
+
escape($tech->tech_name); ?>trade); ?>
+jobs)) : foreach ($tech->jobs as $job) : ?>
+
escape($job->wo_number); ?> escape($job->customer_name ?? ''); ?> escape($job->city ?? ''); ?>
+
No jobs
+
+
+
+
+
+
escape($u->customer_name ?? ''); ?>
escape($u->category ?? $u->trade); ?>
+
+
All assigned
+
+
diff --git a/source/packages/com_mokosuitefield/admin/tmpl/dispatch/default.php b/source/packages/com_mokosuitefield/admin/tmpl/dispatch/default.php
new file mode 100644
index 0000000..4cf6a08
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/tmpl/dispatch/default.php
@@ -0,0 +1,10 @@
+board;$s=$this->stats;
+?>
+
+
+escape($tech->tech_name); ?>
+jobs as $job): ?>
escape($job->wo_number); ?> escape($job->customer_name); ?>
+
+
diff --git a/source/packages/com_mokosuitefield/admin/tmpl/equipment/default.php b/source/packages/com_mokosuitefield/admin/tmpl/equipment/default.php
new file mode 100644
index 0000000..12ec110
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/tmpl/equipment/default.php
@@ -0,0 +1,10 @@
+equipment;$due=$this->serviceDue;
+?>
+ equipment due
+| Type | Make/Model | Serial | Owner | Last Service |
+
+| equipment_type)); ?> | escape($e->make." ".$e->model); ?> | escape($e->serial_number); ?> | escape($e->owner_name); ?> | last_service_date?date("M j",strtotime($e->last_service_date)):"Never"; ?> |
+
+
diff --git a/source/packages/com_mokosuitefield/admin/tmpl/serviceagreements/default.php b/source/packages/com_mokosuitefield/admin/tmpl/serviceagreements/default.php
new file mode 100644
index 0000000..13e6bb3
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/tmpl/serviceagreements/default.php
@@ -0,0 +1,8 @@
+agreements; $rev=$this->revenue; ?>
+active_agreements; ?>
Active Agreements$annual_recurring,0); ?>
Annual Recurring$monthly_recurring,0); ?>
Monthly
+| Agreement | Customer | Trade | Visits | Annual | Status | Expires |
+
+| title); ?> | customer_name??""); ?> | trade); ?> | visits_remaining; ?> of visits_per_year; ?> left | $annual_amount,0); ?> | ">status); ?> | end_date?date("M j, Y",strtotime($a->end_date)):"—"; ?> |
+
+| No agreements |
+
diff --git a/source/packages/com_mokosuitefield/admin/tmpl/technicians/default.php b/source/packages/com_mokosuitefield/admin/tmpl/technicians/default.php
new file mode 100644
index 0000000..8a25af9
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/tmpl/technicians/default.php
@@ -0,0 +1,7 @@
+technicians; $statusColors=["available"=>"success","dispatched"=>"info","en_route"=>"warning","on_site"=>"primary","off_duty"=>"secondary","on_leave"=>"dark"]; ?>
+| Tech | Trade | Status | Phone | Vehicle | License | Jobs/Month |
+
+| tech_name??""); ?> | trade); ?> | ">status)); ?> | telephone??""); ?> | vehicle_number??"—"); ?> | license_number??"—"); ?> | jobs_this_month??0); ?> |
+
+| No technicians |
+
diff --git a/source/packages/com_mokosuitefield/admin/tmpl/vehicles/default.php b/source/packages/com_mokosuitefield/admin/tmpl/vehicles/default.php
new file mode 100644
index 0000000..4ec6984
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/tmpl/vehicles/default.php
@@ -0,0 +1,9 @@
+vehicles;
+?>
+| Vehicle | Make/Model | Assigned To | Mileage | Status |
+
+| escape($v->vehicle_number); ?> | escape($v->make." ".$v->model); ?> | escape($v->assigned_tech_name); ?> | mileage?number_format((int)$v->mileage):"—"; ?> | status); ?> |
+
+
diff --git a/source/packages/com_mokosuitefield/admin/tmpl/workorders/default.php b/source/packages/com_mokosuitefield/admin/tmpl/workorders/default.php
new file mode 100644
index 0000000..00b7a70
--- /dev/null
+++ b/source/packages/com_mokosuitefield/admin/tmpl/workorders/default.php
@@ -0,0 +1,34 @@
+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'];
+?>
+
diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldEquipmentController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldEquipmentController.php
new file mode 100644
index 0000000..881eece
--- /dev/null
+++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldEquipmentController.php
@@ -0,0 +1,178 @@
+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();
+ }
+}
diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldEstimatesController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldEstimatesController.php
new file mode 100644
index 0000000..5e598c9
--- /dev/null
+++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldEstimatesController.php
@@ -0,0 +1,150 @@
+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();
+ }
+}
diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldMobileController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldMobileController.php
new file mode 100644
index 0000000..f433e4c
--- /dev/null
+++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldMobileController.php
@@ -0,0 +1,252 @@
+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();
+ }
+}
diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldWorkOrderController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldWorkOrderController.php
new file mode 100644
index 0000000..d8fbe4c
--- /dev/null
+++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldWorkOrderController.php
@@ -0,0 +1,131 @@
+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();
+ }
+}
diff --git a/source/packages/com_mokosuitefield/media/css/field.css b/source/packages/com_mokosuitefield/media/css/field.css
new file mode 100644
index 0000000..a22318a
--- /dev/null
+++ b/source/packages/com_mokosuitefield/media/css/field.css
@@ -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; } }
diff --git a/source/packages/com_mokosuitefield/media/js/dispatch.js b/source/packages/com_mokosuitefield/media/js/dispatch.js
new file mode 100644
index 0000000..f0d9d81
--- /dev/null
+++ b/source/packages/com_mokosuitefield/media/js/dispatch.js
@@ -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);
+ }
+});
diff --git a/source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php b/source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php
new file mode 100644
index 0000000..9ca2166
--- /dev/null
+++ b/source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php
@@ -0,0 +1,8 @@
+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);
+ }
+}
diff --git a/source/packages/com_mokosuitefield/site/src/View/CustomerPortal/HtmlView.php b/source/packages/com_mokosuitefield/site/src/View/CustomerPortal/HtmlView.php
new file mode 100644
index 0000000..1aef77c
--- /dev/null
+++ b/source/packages/com_mokosuitefield/site/src/View/CustomerPortal/HtmlView.php
@@ -0,0 +1,89 @@
+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);
+ }
+}
diff --git a/source/packages/com_mokosuitefield/site/src/View/EstimateView/HtmlView.php b/source/packages/com_mokosuitefield/site/src/View/EstimateView/HtmlView.php
new file mode 100644
index 0000000..6c6f8eb
--- /dev/null
+++ b/source/packages/com_mokosuitefield/site/src/View/EstimateView/HtmlView.php
@@ -0,0 +1,90 @@
+getInput();
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $token = $input->getString('token', '');
+
+ if (!$token) {
+ $app->enqueueMessage('Invalid estimate link.', 'warning');
+ parent::display($tpl);
+ return;
+ }
+
+ // Load estimate by token
+ $db->setQuery($db->getQuery(true)
+ ->select('e.*, cd.name AS customer_name, cd.email_to, cd.telephone')
+ ->select('l.address, l.city, l.state, l.zip')
+ ->from($db->quoteName('#__mokosuitefield_estimates', 'e'))
+ ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id')
+ ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'l') . ' ON l.id = e.location_id')
+ ->where($db->quoteName('e.approval_token') . ' = ' . $db->quote($token)));
+ $this->estimate = $db->loadObject();
+
+ if (!$this->estimate) {
+ $app->enqueueMessage('Estimate not found or link expired.', 'warning');
+ parent::display($tpl);
+ return;
+ }
+
+ // Load line items
+ $db->setQuery($db->getQuery(true)
+ ->select('*')
+ ->from('#__mokosuitefield_estimate_items')
+ ->where('estimate_id = ' . (int) $this->estimate->id)
+ ->order('ordering ASC'));
+ $this->lineItems = $db->loadObjectList() ?: [];
+
+ // Handle approval/rejection (CSRF check not required — token-based public page)
+ if ($input->getMethod() === 'POST' && \Joomla\CMS\Session\Session::checkToken()) {
+ $action = $input->getString('action', '');
+
+ if ($action === 'approve' && $this->estimate->status === 'sent') {
+ $db->setQuery($db->getQuery(true)
+ ->update('#__mokosuitefield_estimates')
+ ->set($db->quoteName('status') . ' = ' . $db->quote('approved'))
+ ->set($db->quoteName('approved_at') . ' = ' . $db->quote(Factory::getDate()->toSql()))
+ ->set($db->quoteName('customer_signature') . ' = ' . $db->quote($input->getString('signature', '')))
+ ->where('id = ' . (int) $this->estimate->id));
+ $db->execute();
+
+ $this->actioned = true;
+ $this->actionResult = 'approved';
+ $this->estimate->status = 'approved';
+ } elseif ($action === 'reject' && $this->estimate->status === 'sent') {
+ $db->setQuery($db->getQuery(true)
+ ->update('#__mokosuitefield_estimates')
+ ->set($db->quoteName('status') . ' = ' . $db->quote('rejected'))
+ ->set($db->quoteName('rejection_reason') . ' = ' . $db->quote($input->getString('reason', '')))
+ ->where('id = ' . (int) $this->estimate->id));
+ $db->execute();
+
+ $this->actioned = true;
+ $this->actionResult = 'rejected';
+ $this->estimate->status = 'rejected';
+ }
+ }
+
+ parent::display($tpl);
+ }
+}
diff --git a/source/packages/com_mokosuitefield/site/src/View/TechMobile/HtmlView.php b/source/packages/com_mokosuitefield/site/src/View/TechMobile/HtmlView.php
new file mode 100644
index 0000000..7695292
--- /dev/null
+++ b/source/packages/com_mokosuitefield/site/src/View/TechMobile/HtmlView.php
@@ -0,0 +1,70 @@
+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);
+ }
+}
diff --git a/source/packages/com_mokosuitefield/site/tmpl/bookservice/default.php b/source/packages/com_mokosuitefield/site/tmpl/bookservice/default.php
new file mode 100644
index 0000000..237af91
--- /dev/null
+++ b/source/packages/com_mokosuitefield/site/tmpl/bookservice/default.php
@@ -0,0 +1,33 @@
+companyName;
+$trades = $this->trades;
+if ($this->submitted) : ?>
+Service Request Received
We will contact you shortly to schedule your appointment.
+
+
+
Request Service
+
escape($company); ?>
+
+
diff --git a/source/packages/com_mokosuitefield/site/tmpl/customerportal/default.php b/source/packages/com_mokosuitefield/site/tmpl/customerportal/default.php
new file mode 100644
index 0000000..9f7da25
--- /dev/null
+++ b/source/packages/com_mokosuitefield/site/tmpl/customerportal/default.php
@@ -0,0 +1,14 @@
+contactId) return;
+$active=$this->activeOrders;$history=$this->orderHistory;$next=$this->nextService;
+?>
+My Service Portal
+
Next Service
trade); ?> — scheduled_date)); ?>
+
| WO# | Service | Status |
+escape($wo->wo_number); ?> | trade); ?> | status)); ?> |
+
+
| WO# | Service | Total | Date |
+escape($h->wo_number); ?> | trade); ?> | total>0?"$".number_format((float)$h->total,2):""; ?> | completed_at?date("M j",strtotime($h->completed_at)):""; ?> |
+
+
diff --git a/source/packages/com_mokosuitefield/site/tmpl/estimateview/default.php b/source/packages/com_mokosuitefield/site/tmpl/estimateview/default.php
new file mode 100644
index 0000000..50fa071
--- /dev/null
+++ b/source/packages/com_mokosuitefield/site/tmpl/estimateview/default.php
@@ -0,0 +1,96 @@
+estimate) return;
+$e = $this->estimate;
+$subtotal = 0;
+foreach ($this->lineItems as $li) { $subtotal += (float) $li->line_total; }
+$tax = round($subtotal * 0.07, 2);
+$total = $subtotal + $tax;
+?>
+
+ actioned) : ?>
+
+
Estimate actionResult); ?>
+
actionResult === 'approved' ? 'Thank you! We will schedule the work shortly.' : 'The estimate has been declined.'; ?>
+
+
+
+
+
+
+
+
+ Customer: customer_name ?? ''); ?>
+ address) : ?>Location: address . ', ' . $e->city . ', ' . $e->state . ' ' . $e->zip); ?>
+
+
+ Estimate #: estimate_number ?? $e->id); ?>
+ Date: created)); ?>
+ valid_until) : ?>Valid Until: valid_until)); ?>
+
+
+
+ description) : ?>
+
Scope of Work: description)); ?>
+
+
+
+ | Description | Qty | Unit Price | Total |
+
+ lineItems as $li) : ?>
+
+ | description); ?> |
+ quantity; ?> |
+ $unit_price, 2); ?> |
+ $line_total, 2); ?> |
+
+
+
+
+ | Subtotal | $ |
+ | Tax | $ |
+ | Total | $ |
+
+
+
+
+
+ status === 'sent') : ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/source/packages/com_mokosuitefield/site/tmpl/techmobile/default.php b/source/packages/com_mokosuitefield/site/tmpl/techmobile/default.php
new file mode 100644
index 0000000..691ee15
--- /dev/null
+++ b/source/packages/com_mokosuitefield/site/tmpl/techmobile/default.php
@@ -0,0 +1,56 @@
+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'];
+?>
+
+
+
escape($tech->tech_name); ?>
+status)); ?>
+
+
+
+
+
+
+
escape($current->customer_name); ?>
+
escape($current->address); ?>, escape($current->city); ?>
+customer_phone) : ?>
customer_phone; ?>
+access_notes) : ?>
Access: escape($current->access_notes); ?>
+
escape($current->description); ?>
+
+status === 'dispatched') : ?>
+status === 'en_route') : ?>
+status === 'on_site') : ?>
+status === 'in_progress') : ?>
+latitude && $current->longitude) : ?>
+
+
+
+
+
+
+
+
Today ( jobs)
+id === $current->id) continue; ?>
+
+
+
escape($job->customer_name); ?>status)); ?>
+
escape($job->city??''); ?> | scheduled_time_start?date('g:ia',strtotime($job->scheduled_time_start)):'TBD'; ?> | trade); ?>
+
+
+
+
No jobs today.
+
+
diff --git a/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini b/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini
new file mode 100644
index 0000000..c507f86
--- /dev/null
+++ b/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini
@@ -0,0 +1,2 @@
+PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field"
+PLG_SYSTEM_MOKOSUITEFIELD_DESC="MokoSuite Field Service system plugin - database schema and helpers."
diff --git a/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini b/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini
new file mode 100644
index 0000000..26bf5c4
--- /dev/null
+++ b/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini
@@ -0,0 +1,2 @@
+PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field"
+PLG_SYSTEM_MOKOSUITEFIELD_DESC="MokoSuite Field Service system plugin."
diff --git a/source/packages/plg_system_mokosuitefield/sql/install.mysql.sql b/source/packages/plg_system_mokosuitefield/sql/install.mysql.sql
new file mode 100644
index 0000000..e0a9d8d
--- /dev/null
+++ b/source/packages/plg_system_mokosuitefield/sql/install.mysql.sql
@@ -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;
diff --git a/source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql b/source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql
new file mode 100644
index 0000000..e804a60
--- /dev/null
+++ b/source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql
@@ -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`;
diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php
new file mode 100644
index 0000000..487b566
--- /dev/null
+++ b/source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php
@@ -0,0 +1,144 @@
+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() ?: [];
+ }
+}
diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/EquipmentHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/EquipmentHelper.php
new file mode 100644
index 0000000..1d56d11
--- /dev/null
+++ b/source/packages/plg_system_mokosuitefield/src/Helper/EquipmentHelper.php
@@ -0,0 +1,77 @@
+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');
+ }
+}
diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/EstimateHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/EstimateHelper.php
new file mode 100644
index 0000000..3ef628c
--- /dev/null
+++ b/source/packages/plg_system_mokosuitefield/src/Helper/EstimateHelper.php
@@ -0,0 +1,83 @@
+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;
+ }
+}
diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/InvoiceHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/InvoiceHelper.php
new file mode 100644
index 0000000..83e51b7
--- /dev/null
+++ b/source/packages/plg_system_mokosuitefield/src/Helper/InvoiceHelper.php
@@ -0,0 +1,151 @@
+get(DatabaseInterface::class);
+ $now = Factory::getDate()->toSql();
+ $woId = (int) $woId;
+
+ // Load work order
+ $db->setQuery($db->getQuery(true)
+ ->select('wo.*, cd.name AS customer_name')
+ ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
+ ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
+ ->where('wo.id = ' . $woId));
+ $wo = $db->loadObject();
+
+ if (!$wo) throw new \RuntimeException('Work order not found: ' . $woId);
+
+ $db->transactionStart();
+
+ $params = Factory::getApplication()->getParams('com_mokosuitefield');
+ $laborRate = (float) $params->get('default_labor_rate', 85.00);
+
+ // Get time entries
+ $db->setQuery($db->getQuery(true)
+ ->select('*')
+ ->from('#__mokosuitefield_time_entries')
+ ->where('wo_id = ' . $woId));
+ $timeEntries = $db->loadObjectList() ?: [];
+
+ $totalHours = 0;
+ foreach ($timeEntries as $te) {
+ $totalHours += (float) $te->hours;
+ }
+
+ // Get parts used
+ $db->setQuery($db->getQuery(true)
+ ->select('wi.*, p.title AS part_name, p.price')
+ ->from($db->quoteName('#__mokosuitefield_wo_items', 'wi'))
+ ->join('LEFT', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = wi.product_id')
+ ->where('wi.wo_id = ' . $woId));
+ $parts = $db->loadObjectList() ?: [];
+
+ $partsTotal = 0;
+ foreach ($parts as $part) {
+ $partsTotal += (float) ($part->price ?? 0) * (int) $part->quantity;
+ }
+
+ $laborTotal = round($totalHours * $laborRate, 2);
+ $subtotal = $laborTotal + $partsTotal;
+ $taxRate = (float) $params->get('default_tax_rate', 0.07);
+ $tax = round($subtotal * $taxRate, 2);
+ $total = $subtotal + $tax;
+
+ // Create CRM invoice
+ $seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuite_crm_invoices'))->loadResult() + 1;
+
+ $invoice = (object) [
+ 'contact_id' => $wo->contact_id,
+ 'invoice_number' => 'FSI-' . date('Ymd') . '-' . str_pad($seq, 4, '0', STR_PAD_LEFT),
+ 'type' => 'standard',
+ 'subtotal' => $subtotal,
+ 'tax' => $tax,
+ 'total' => $total,
+ 'balance_due' => $total,
+ 'status' => 'draft',
+ 'due_date' => date('Y-m-d', strtotime('+30 days')),
+ 'notes' => 'Field Service Invoice for WO #' . ($wo->wo_number ?? $woId),
+ 'created' => $now,
+ 'created_by' => Factory::getApplication()->getIdentity()->id,
+ ];
+ $db->insertObject('#__mokosuite_crm_invoices', $invoice, 'id');
+ $invoiceId = (int) $invoice->id;
+
+ // Add labor line item
+ if ($totalHours > 0) {
+ $db->insertObject('#__mokosuite_crm_invoice_items', (object) [
+ 'invoice_id' => $invoiceId,
+ 'description' => 'Labor — ' . number_format($totalHours, 1) . ' hours @ $' . number_format($laborRate, 2) . '/hr',
+ 'quantity' => $totalHours,
+ 'unit_price' => $laborRate,
+ 'line_total' => $laborTotal,
+ ]);
+ }
+
+ // Add parts line items
+ foreach ($parts as $part) {
+ $db->insertObject('#__mokosuite_crm_invoice_items', (object) [
+ 'invoice_id' => $invoiceId,
+ 'product_id' => $part->product_id,
+ 'description' => $part->part_name ?? 'Part',
+ 'quantity' => $part->quantity,
+ 'unit_price' => $part->price ?? 0,
+ 'line_total' => round((float) ($part->price ?? 0) * (int) $part->quantity, 2),
+ ]);
+ }
+
+ // Link invoice to work order
+ $db->setQuery($db->getQuery(true)
+ ->update('#__mokosuitefield_work_orders')
+ ->set('invoice_id = ' . $invoiceId)
+ ->where('id = ' . $woId));
+ $db->execute();
+
+ $db->transactionCommit();
+
+ return $invoiceId;
+ }
+
+ /**
+ * Batch generate invoices for all completed, uninvoiced work orders.
+ */
+ public static function batchGenerate(): array
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $db->setQuery($db->getQuery(true)
+ ->select('id')
+ ->from('#__mokosuitefield_work_orders')
+ ->where($db->quoteName('status') . ' = ' . $db->quote('completed'))
+ ->where('invoice_id IS NULL OR invoice_id = 0')
+ ->order('completed_at ASC'));
+ $woIds = $db->loadColumn() ?: [];
+
+ $results = [];
+ foreach ($woIds as $woId) {
+ try {
+ $invoiceId = self::generateFromWorkOrder((int) $woId);
+ $results[] = ['wo_id' => $woId, 'invoice_id' => $invoiceId, 'success' => true];
+ } catch (\Throwable $e) {
+ $results[] = ['wo_id' => $woId, 'error' => $e->getMessage(), 'success' => false];
+ }
+ }
+
+ return $results;
+ }
+}
diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php
new file mode 100644
index 0000000..701348c
--- /dev/null
+++ b/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php
@@ -0,0 +1,239 @@
+get(DatabaseInterface::class);
+ $date = $date ?: date('Y-m-d');
+
+ $db->setQuery($db->getQuery(true)
+ ->select('wo.id, wo.wo_number, wo.priority, wo.status, wo.trade')
+ ->select('wo.scheduled_date, wo.scheduled_time, wo.estimated_duration')
+ ->select('wo.route_order')
+ ->select('l.name AS location_name, l.address, l.city, l.state, l.zip')
+ ->select('l.latitude, l.longitude')
+ ->select('cd.name AS customer_name, cd.telephone AS customer_phone')
+ ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
+ ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'l') . ' ON l.id = wo.location_id')
+ ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
+ ->where($db->quoteName('wo.technician_id') . ' = ' . (int) $techId)
+ ->where($db->quoteName('wo.scheduled_date') . ' = ' . $db->quote($date))
+ ->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('cancelled') . ',' . $db->quote('completed') . ')')
+ ->order('wo.route_order ASC, wo.scheduled_time ASC'));
+
+ return $db->loadObjectList() ?: [];
+ }
+
+ /**
+ * Auto-assign route order based on geographic proximity (nearest-neighbor heuristic).
+ * Starts from the tech's home base or first WO location.
+ */
+ public static function optimizeRoute(int $techId, string $date = ''): array
+ {
+ $stops = self::getTechRoute($techId, $date);
+ if (count($stops) <= 1) return $stops;
+
+ // Get tech home location
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+ $db->setQuery($db->getQuery(true)
+ ->select('home_latitude, home_longitude')
+ ->from('#__mokosuitefield_technicians')
+ ->where('id = ' . (int) $techId));
+ $tech = $db->loadObject();
+
+ $currentLat = (float) ($tech->home_latitude ?? 0);
+ $currentLng = (float) ($tech->home_longitude ?? 0);
+
+ // Nearest-neighbor sort
+ $ordered = [];
+ $remaining = $stops;
+
+ while (!empty($remaining)) {
+ $bestIdx = 0;
+ $bestDist = PHP_FLOAT_MAX;
+
+ foreach ($remaining as $idx => $stop) {
+ $lat = (float) ($stop->latitude ?? 0);
+ $lng = (float) ($stop->longitude ?? 0);
+
+ if ($lat === 0.0 && $lng === 0.0) {
+ // No coordinates — keep in original position
+ $dist = PHP_FLOAT_MAX - 1;
+ } else {
+ $dist = self::haversine($currentLat, $currentLng, $lat, $lng);
+ }
+
+ if ($dist < $bestDist) {
+ $bestDist = $dist;
+ $bestIdx = $idx;
+ }
+ }
+
+ $next = $remaining[$bestIdx];
+ $ordered[] = $next;
+ $currentLat = (float) ($next->latitude ?? $currentLat);
+ $currentLng = (float) ($next->longitude ?? $currentLng);
+ array_splice($remaining, $bestIdx, 1);
+ $remaining = array_values($remaining);
+ }
+
+ // Save route order
+ foreach ($ordered as $i => $stop) {
+ $update = (object) [
+ 'id' => $stop->id,
+ 'route_order' => $i + 1,
+ ];
+ $db->updateObject('#__mokosuitefield_work_orders', $update, 'id');
+ $ordered[$i]->route_order = $i + 1;
+ }
+
+ return $ordered;
+ }
+
+ /**
+ * Manually reorder a stop within a tech's route.
+ */
+ public static function reorderStop(int $woId, int $newPosition): bool
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $db->setQuery($db->getQuery(true)
+ ->select('technician_id, scheduled_date')
+ ->from('#__mokosuitefield_work_orders')
+ ->where('id = ' . (int) $woId));
+ $wo = $db->loadObject();
+ if (!$wo) return false;
+
+ $stops = self::getTechRoute((int) $wo->technician_id, $wo->scheduled_date);
+
+ // Find and remove the target WO
+ $target = null;
+ $filtered = [];
+ foreach ($stops as $stop) {
+ if ((int) $stop->id === $woId) {
+ $target = $stop;
+ } else {
+ $filtered[] = $stop;
+ }
+ }
+
+ if (!$target) return false;
+
+ // Insert at new position
+ $newPosition = max(1, min($newPosition, count($filtered) + 1));
+ array_splice($filtered, $newPosition - 1, 0, [$target]);
+
+ // Save new order
+ foreach ($filtered as $i => $stop) {
+ $update = (object) [
+ 'id' => $stop->id,
+ 'route_order' => $i + 1,
+ ];
+ $db->updateObject('#__mokosuitefield_work_orders', $update, 'id');
+ }
+
+ return true;
+ }
+
+ /**
+ * Estimate total drive time and distance for a route (using straight-line approximation).
+ */
+ public static function estimateRouteMetrics(int $techId, string $date = ''): object
+ {
+ $stops = self::getTechRoute($techId, $date);
+
+ $totalDistance = 0.0;
+ $totalJobTime = 0;
+ $legs = [];
+
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+ $db->setQuery($db->getQuery(true)
+ ->select('home_latitude, home_longitude')
+ ->from('#__mokosuitefield_technicians')
+ ->where('id = ' . (int) $techId));
+ $tech = $db->loadObject();
+
+ $prevLat = (float) ($tech->home_latitude ?? 0);
+ $prevLng = (float) ($tech->home_longitude ?? 0);
+
+ foreach ($stops as $stop) {
+ $lat = (float) ($stop->latitude ?? 0);
+ $lng = (float) ($stop->longitude ?? 0);
+ $dist = 0;
+
+ if ($lat && $lng && ($prevLat || $prevLng)) {
+ $dist = self::haversine($prevLat, $prevLng, $lat, $lng);
+ }
+
+ $totalDistance += $dist;
+ $totalJobTime += (int) ($stop->estimated_duration ?? 60);
+
+ $legs[] = (object) [
+ 'wo_id' => $stop->id,
+ 'location' => $stop->location_name ?? $stop->address,
+ 'distance' => round($dist, 1),
+ 'drive_min'=> round($dist / self::AVG_SPEED_MPH * 60, 0),
+ ];
+
+ $prevLat = $lat ?: $prevLat;
+ $prevLng = $lng ?: $prevLng;
+ }
+
+ $totalDriveMin = $totalDistance > 0 ? round($totalDistance / self::AVG_SPEED_MPH * 60) : 0;
+
+ return (object) [
+ 'stop_count' => count($stops),
+ 'total_distance' => round($totalDistance, 1),
+ 'total_drive_min'=> $totalDriveMin,
+ 'total_job_min' => $totalJobTime,
+ 'total_day_min' => $totalDriveMin + $totalJobTime,
+ 'legs' => $legs,
+ ];
+ }
+
+ /**
+ * Haversine distance in miles.
+ */
+ private static function haversine(float $lat1, float $lon1, float $lat2, float $lon2): float
+ {
+ $R = 3959; // Earth radius in miles
+ $dLat = deg2rad($lat2 - $lat1);
+ $dLon = deg2rad($lon2 - $lon1);
+ $a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon / 2) ** 2;
+ $c = 2 * atan2(sqrt($a), sqrt(1 - $a));
+ return $R * $c;
+ }
+
+ /**
+ * Log a GPS breadcrumb for a tech (called from mobile app).
+ */
+ public static function logGpsBreadcrumb(int $techId, float $lat, float $lng, ?int $woId = null): void
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $crumb = (object) [
+ 'technician_id' => $techId,
+ 'latitude' => $lat,
+ 'longitude' => $lng,
+ 'wo_id' => $woId,
+ 'recorded_at' => Factory::getDate()->toSql(),
+ ];
+
+ $db->insertObject('#__mokosuitefield_dispatch_log', $crumb);
+ }
+}
diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/ServiceAgreementHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/ServiceAgreementHelper.php
new file mode 100644
index 0000000..ab2f919
--- /dev/null
+++ b/source/packages/plg_system_mokosuitefield/src/Helper/ServiceAgreementHelper.php
@@ -0,0 +1,78 @@
+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();
+ }
+}
diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/TruckStockHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/TruckStockHelper.php
new file mode 100644
index 0000000..fbd7aa6
--- /dev/null
+++ b/source/packages/plg_system_mokosuitefield/src/Helper/TruckStockHelper.php
@@ -0,0 +1,64 @@
+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();
+ }
+}
diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/VehicleHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/VehicleHelper.php
new file mode 100644
index 0000000..0d32603
--- /dev/null
+++ b/source/packages/plg_system_mokosuitefield/src/Helper/VehicleHelper.php
@@ -0,0 +1,44 @@
+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() ?: [];
+ }
+}
diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php
new file mode 100644
index 0000000..e6270e2
--- /dev/null
+++ b/source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php
@@ -0,0 +1,162 @@
+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];
+ }
+}
diff --git a/source/packages/plg_task_mokosuitefield/src/Extension/FieldAutomation.php b/source/packages/plg_task_mokosuitefield/src/Extension/FieldAutomation.php
new file mode 100644
index 0000000..9fbdc96
--- /dev/null
+++ b/source/packages/plg_task_mokosuitefield/src/Extension/FieldAutomation.php
@@ -0,0 +1,144 @@
+ [
+ '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;
+ }
+}
diff --git a/source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteFieldApi.php b/source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteFieldApi.php
new file mode 100644
index 0000000..4dfb1fa
--- /dev/null
+++ b/source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteFieldApi.php
@@ -0,0 +1,27 @@
+ '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']);
+ }
+}
diff --git a/source/pkg_mokosuitefield.xml b/source/pkg_mokosuitefield.xml
new file mode 100644
index 0000000..849de34
--- /dev/null
+++ b/source/pkg_mokosuitefield.xml
@@ -0,0 +1,24 @@
+
+
+ Package - MokoSuite Field
+ mokosuitefield
+ 01.01.00
+ 2026-06-12
+ Moko Consulting
+ hello@mokoconsulting.tech
+ https://mokoconsulting.tech
+ Copyright (C) 2026 Moko Consulting. All rights reserved.
+ GNU General Public License version 3 or later; see LICENSE
+ MokoSuite Field Service - dispatch, work orders, scheduling, mobile tech. Layer 2 add-on for MokoSuite (requires CRM).
+ 8.3
+
+ true
+
+ plg_system_mokosuitefield.zip
+ com_mokosuitefield.zip
+ plg_webservices_mokosuitefield.zip
+
+
+ https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteField/updates.xml
+
+
diff --git a/source/updates.xml b/source/updates.xml
new file mode 100644
index 0000000..39bccee
--- /dev/null
+++ b/source/updates.xml
@@ -0,0 +1,9 @@
+
+
+Package - MokoSuite Field
+pkg_mokosuitefield
+package
+01.01.00
+
+8.3
+