From 5a498cf3f6b73fc57207bde3fc8bb6c66c8159e7 Mon Sep 17 00:00:00 2001 From: Jonathan Miller <1+jmiller@noreply.git.mokoconsulting.tech> Date: Sat, 27 Jun 2026 20:17:43 +0000 Subject: [PATCH] feat: initial scaffold with component, system plugin, and webservices plugin --- .gitignore | 7 + CHANGELOG.md | 23 +- CLAUDE.md | 25 ++ README.md | 26 +- .../components/com_mokosuitefield/access.xml | 17 + .../com_mokosuitefield/com_mokosuitefield.xml | 46 +++ .../components/com_mokosuitefield/config.xml | 19 + .../language/en-GB/com_mokosuitefield.ini | 14 + .../language/en-GB/com_mokosuitefield.sys.ini | 14 + .../com_mokosuitefield/services/provider.php | 41 +++ .../src/Controller/DisplayController.php | 18 + .../src/View/FieldAgreements/HtmlView.php | 24 ++ .../src/View/FieldChecklists/HtmlView.php | 24 ++ .../src/View/FieldDashboard/HtmlView.php | 24 ++ .../src/View/FieldDispatches/HtmlView.php | 24 ++ .../src/View/FieldEquipment/HtmlView.php | 24 ++ .../src/View/FieldParts/HtmlView.php | 24 ++ .../src/View/FieldTechnicians/HtmlView.php | 24 ++ .../src/View/FieldWorkorders/HtmlView.php | 24 ++ .../tmpl/fieldagreements/default.php | 13 + .../tmpl/fieldchecklists/default.php | 13 + .../tmpl/fielddashboard/default.php | 13 + .../tmpl/fielddispatches/default.php | 13 + .../tmpl/fieldequipment/default.php | 13 + .../tmpl/fieldparts/default.php | 13 + .../tmpl/fieldtechnicians/default.php | 13 + .../tmpl/fieldworkorders/default.php | 13 + .../com_mokosuitefield/admin/access.xml | 11 - .../com_mokosuitefield/admin/config.xml | 25 -- .../admin/services/provider.php | 18 - .../src/Controller/DisplayController.php | 11 - .../admin/src/Model/DispatchModel.php | 37 -- .../admin/src/Model/EquipmentModel.php | 29 -- .../admin/src/Model/EstimatesModel.php | 28 -- .../src/Model/ServiceAgreementsModel.php | 39 -- .../admin/src/Model/TechniciansModel.php | 27 -- .../admin/src/Model/VehiclesModel.php | 26 -- .../admin/src/Model/WorkOrdersModel.php | 62 ---- .../admin/src/View/Dashboard/HtmlView.php | 40 --- .../admin/src/View/Dispatch/HtmlView.php | 28 -- .../admin/src/View/Equipment/HtmlView.php | 34 -- .../src/View/ServiceAgreements/HtmlView.php | 23 -- .../admin/src/View/Technicians/HtmlView.php | 32 -- .../admin/src/View/Vehicles/HtmlView.php | 23 -- .../admin/src/View/WorkOrders/HtmlView.php | 52 --- .../admin/tmpl/dashboard/default.php | 33 -- .../admin/tmpl/dispatch/default.php | 10 - .../admin/tmpl/equipment/default.php | 10 - .../admin/tmpl/serviceagreements/default.php | 8 - .../admin/tmpl/technicians/default.php | 7 - .../admin/tmpl/vehicles/default.php | 9 - .../admin/tmpl/workorders/default.php | 34 -- .../Controller/FieldEquipmentController.php | 178 ---------- .../Controller/FieldEstimatesController.php | 150 -------- .../src/Controller/FieldMobileController.php | 252 ------------- .../src/Controller/FieldReportsController.php | 122 ------- .../Controller/FieldSchedulingController.php | 83 ----- .../Controller/FieldWorkOrderController.php | 131 ------- .../com_mokosuitefield/media/css/field.css | 9 - .../com_mokosuitefield/media/js/dispatch.js | 6 - .../site/src/Controller/DisplayController.php | 8 - .../site/src/Service/Router.php | 21 -- .../site/src/View/BookService/HtmlView.php | 78 ---- .../site/src/View/CustomerPortal/HtmlView.php | 89 ----- .../site/src/View/EstimateView/HtmlView.php | 90 ----- .../site/src/View/TechMobile/HtmlView.php | 70 ---- .../site/tmpl/bookservice/default.php | 33 -- .../site/tmpl/customerportal/default.php | 14 - .../site/tmpl/estimateview/default.php | 96 ----- .../site/tmpl/techmobile/default.php | 56 --- .../en-GB/plg_system_mokosuitefield.ini | 2 - .../en-GB/plg_system_mokosuitefield.sys.ini | 2 - .../sql/install.mysql.sql | 332 ------------------ .../sql/uninstall.mysql.sql | 12 - .../src/Helper/CustomerFeedbackHelper.php | 132 ------- .../src/Helper/CustomerSatisfactionHelper.php | 118 ------- .../src/Helper/DispatchHelper.php | 144 -------- .../src/Helper/EquipmentHelper.php | 77 ---- .../src/Helper/EstimateHelper.php | 83 ----- .../src/Helper/GpsTrackingHelper.php | 112 ------ .../src/Helper/InvoiceHelper.php | 151 -------- .../src/Helper/PartsHelper.php | 107 ------ .../src/Helper/RouteHelper.php | 239 ------------- .../src/Helper/SafetyChecklistHelper.php | 147 -------- .../src/Helper/SchedulingHelper.php | 152 -------- .../src/Helper/ServiceAgreementHelper.php | 78 ---- .../src/Helper/TechnicianSkillHelper.php | 97 ----- .../src/Helper/TruckStockHelper.php | 69 ---- .../src/Helper/VehicleHelper.php | 44 --- .../src/Helper/WarrantyHelper.php | 102 ------ .../src/Helper/WorkOrderHelper.php | 162 --------- .../src/Extension/FieldAutomation.php | 144 -------- .../src/Extension/MokoSuiteFieldApi.php | 27 -- source/pkg_mokosuitefield.xml | 46 +-- .../en-GB/plg_system_mokosuitefield.ini | 13 + .../en-GB/plg_system_mokosuitefield.sys.ini | 6 + .../system/mokosuitefield/mokosuitefield.xml | 106 ++++++ .../mokosuitefield/services/provider.php | 36 ++ .../system/mokosuitefield/sql/install.sql | 170 +++++++++ .../system/mokosuitefield/sql/uninstall.sql | 14 + .../src/Extension/MokoSuiteField.php | 23 ++ .../en-GB/plg_webservices_mokosuitefield.ini | 6 + .../plg_webservices_mokosuitefield.sys.ini | 6 + .../mokosuitefield/mokosuitefield.xml | 30 ++ .../mokosuitefield/services/provider.php | 36 ++ .../src/Extension/MokoSuiteField.php | 50 +++ 106 files changed, 1057 insertions(+), 4706 deletions(-) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 source/components/com_mokosuitefield/access.xml create mode 100644 source/components/com_mokosuitefield/com_mokosuitefield.xml create mode 100644 source/components/com_mokosuitefield/config.xml create mode 100644 source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.ini create mode 100644 source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.sys.ini create mode 100644 source/components/com_mokosuitefield/services/provider.php create mode 100644 source/components/com_mokosuitefield/src/Controller/DisplayController.php create mode 100644 source/components/com_mokosuitefield/src/View/FieldAgreements/HtmlView.php create mode 100644 source/components/com_mokosuitefield/src/View/FieldChecklists/HtmlView.php create mode 100644 source/components/com_mokosuitefield/src/View/FieldDashboard/HtmlView.php create mode 100644 source/components/com_mokosuitefield/src/View/FieldDispatches/HtmlView.php create mode 100644 source/components/com_mokosuitefield/src/View/FieldEquipment/HtmlView.php create mode 100644 source/components/com_mokosuitefield/src/View/FieldParts/HtmlView.php create mode 100644 source/components/com_mokosuitefield/src/View/FieldTechnicians/HtmlView.php create mode 100644 source/components/com_mokosuitefield/src/View/FieldWorkorders/HtmlView.php create mode 100644 source/components/com_mokosuitefield/tmpl/fieldagreements/default.php create mode 100644 source/components/com_mokosuitefield/tmpl/fieldchecklists/default.php create mode 100644 source/components/com_mokosuitefield/tmpl/fielddashboard/default.php create mode 100644 source/components/com_mokosuitefield/tmpl/fielddispatches/default.php create mode 100644 source/components/com_mokosuitefield/tmpl/fieldequipment/default.php create mode 100644 source/components/com_mokosuitefield/tmpl/fieldparts/default.php create mode 100644 source/components/com_mokosuitefield/tmpl/fieldtechnicians/default.php create mode 100644 source/components/com_mokosuitefield/tmpl/fieldworkorders/default.php delete mode 100644 source/packages/com_mokosuitefield/admin/access.xml delete mode 100644 source/packages/com_mokosuitefield/admin/config.xml delete mode 100644 source/packages/com_mokosuitefield/admin/services/provider.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/Controller/DisplayController.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/Model/DispatchModel.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/Model/EquipmentModel.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/Model/EstimatesModel.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/Model/ServiceAgreementsModel.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/Model/TechniciansModel.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/Model/VehiclesModel.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/Model/WorkOrdersModel.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/View/Dashboard/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/View/Dispatch/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/View/Equipment/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/View/ServiceAgreements/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/View/Technicians/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/View/Vehicles/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/View/WorkOrders/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/admin/tmpl/dashboard/default.php delete mode 100644 source/packages/com_mokosuitefield/admin/tmpl/dispatch/default.php delete mode 100644 source/packages/com_mokosuitefield/admin/tmpl/equipment/default.php delete mode 100644 source/packages/com_mokosuitefield/admin/tmpl/serviceagreements/default.php delete mode 100644 source/packages/com_mokosuitefield/admin/tmpl/technicians/default.php delete mode 100644 source/packages/com_mokosuitefield/admin/tmpl/vehicles/default.php delete mode 100644 source/packages/com_mokosuitefield/admin/tmpl/workorders/default.php delete mode 100644 source/packages/com_mokosuitefield/api/src/Controller/FieldEquipmentController.php delete mode 100644 source/packages/com_mokosuitefield/api/src/Controller/FieldEstimatesController.php delete mode 100644 source/packages/com_mokosuitefield/api/src/Controller/FieldMobileController.php delete mode 100644 source/packages/com_mokosuitefield/api/src/Controller/FieldReportsController.php delete mode 100644 source/packages/com_mokosuitefield/api/src/Controller/FieldSchedulingController.php delete mode 100644 source/packages/com_mokosuitefield/api/src/Controller/FieldWorkOrderController.php delete mode 100644 source/packages/com_mokosuitefield/media/css/field.css delete mode 100644 source/packages/com_mokosuitefield/media/js/dispatch.js delete mode 100644 source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php delete mode 100644 source/packages/com_mokosuitefield/site/src/Service/Router.php delete mode 100644 source/packages/com_mokosuitefield/site/src/View/BookService/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/site/src/View/CustomerPortal/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/site/src/View/EstimateView/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/site/src/View/TechMobile/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/site/tmpl/bookservice/default.php delete mode 100644 source/packages/com_mokosuitefield/site/tmpl/customerportal/default.php delete mode 100644 source/packages/com_mokosuitefield/site/tmpl/estimateview/default.php delete mode 100644 source/packages/com_mokosuitefield/site/tmpl/techmobile/default.php delete mode 100644 source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini delete mode 100644 source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini delete mode 100644 source/packages/plg_system_mokosuitefield/sql/install.mysql.sql delete mode 100644 source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/CustomerFeedbackHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/CustomerSatisfactionHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/EquipmentHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/EstimateHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/GpsTrackingHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/InvoiceHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/PartsHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/SafetyChecklistHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/SchedulingHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/ServiceAgreementHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/TechnicianSkillHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/TruckStockHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/VehicleHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/WarrantyHelper.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php delete mode 100644 source/packages/plg_task_mokosuitefield/src/Extension/FieldAutomation.php delete mode 100644 source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteFieldApi.php create mode 100644 source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini create mode 100644 source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini create mode 100644 source/plugins/system/mokosuitefield/mokosuitefield.xml create mode 100644 source/plugins/system/mokosuitefield/services/provider.php create mode 100644 source/plugins/system/mokosuitefield/sql/install.sql create mode 100644 source/plugins/system/mokosuitefield/sql/uninstall.sql create mode 100644 source/plugins/system/mokosuitefield/src/Extension/MokoSuiteField.php create mode 100644 source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.ini create mode 100644 source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.sys.ini create mode 100644 source/plugins/webservices/mokosuitefield/mokosuitefield.xml create mode 100644 source/plugins/webservices/mokosuitefield/services/provider.php create mode 100644 source/plugins/webservices/mokosuitefield/src/Extension/MokoSuiteField.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8a642e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.claude/ +.mcp.json +TODO.md +*.min.css +*.min.js +vendor/ +node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d001f04..f76722d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,17 @@ + + # Changelog -## [01.01.00] - 2026-06-12 +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + ### Added -- Initial scaffold: field service management for MokoSuite -- 12 database tables for technicians, work orders, service agreements, equipment, vehicles, estimates -- 7 helpers: Dispatch, WorkOrder, ServiceAgreement, Equipment, Estimate, TruckStock, Vehicle -- Admin views: Dashboard, Work Orders, Technicians, Service Agreements, Equipment, Dispatch, Vehicles -- Site views: Tech Mobile (tablet), Book Service (public form) -- API controller with 6 endpoints -- Task scheduler: service reminders, agreement renewals, equipment warranty, truck stock reorder -- Joomla 6 architecture (PHP 8.3+) + +- **Initial scaffold** — Package structure with component, system plugin, and webservices plugin diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1f90bfc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,25 @@ + + +# MokoSuiteField + +Field service management for contractors — a Layer 2 vertical for the MokoSuite platform. + +## Structure + +- `source/` — Joomla extension source (package, component, plugins) +- All work happens on the `dev` branch; never commit directly to `main` +- Use conventional commits (`feat:`, `fix:`, `chore:`, etc.) + +## Build + +Packaged via MokoCLI. Run `mokocli build` from the repo root. + +## Standards + +- PHP 8.3+ / Joomla 6 architecture +- `$this->getDatabase()` — never use `Factory::getDbo()` +- Namespace root: `Moko\Component\MokoSuiteField` (component), `Moko\Plugin\System\MokoSuiteField` (system plugin), `Moko\Plugin\WebServices\MokoSuiteField` (API plugin) diff --git a/README.md b/README.md index 74f5607..f45cd5c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,27 @@ + + # MokoSuiteField -MokoSuite Field Service - dispatch, work orders, scheduling, mobile tech, plumbing, electrical, HVAC, service agreements. Layer 2 add-on for MokoSuite (requires CRM). \ No newline at end of file +Field service management for contractors — a MokoSuite Layer 2 vertical extension for Joomla. + +## Overview + +MokoSuiteField provides work order management, technician dispatch, equipment tracking, parts inventory, checklists, and preventive maintenance agreements for field service businesses. + +## Requirements + +- Joomla 6.x +- PHP 8.3+ +- MokoSuite (base platform) + +## Installation + +Install via the MokoSuite package manager or upload `pkg_mokosuitefield.zip` through Joomla's extension installer. + +## License + +GPL-3.0-or-later diff --git a/source/components/com_mokosuitefield/access.xml b/source/components/com_mokosuitefield/access.xml new file mode 100644 index 0000000..1a23e16 --- /dev/null +++ b/source/components/com_mokosuitefield/access.xml @@ -0,0 +1,17 @@ + + + +
+ + + + + + + +
+
diff --git a/source/components/com_mokosuitefield/com_mokosuitefield.xml b/source/components/com_mokosuitefield/com_mokosuitefield.xml new file mode 100644 index 0000000..a3a73f0 --- /dev/null +++ b/source/components/com_mokosuitefield/com_mokosuitefield.xml @@ -0,0 +1,46 @@ + + + + com_mokosuitefield + 0.1.0 + 2026-06-27 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting + GPL-3.0-or-later + COM_MOKOSUITEFIELD_DESCRIPTION + + Moko\Component\MokoSuiteField + + + COM_MOKOSUITEFIELD + + COM_MOKOSUITEFIELD_MENU_DASHBOARD + COM_MOKOSUITEFIELD_MENU_WORKORDERS + COM_MOKOSUITEFIELD_MENU_TECHNICIANS + COM_MOKOSUITEFIELD_MENU_EQUIPMENT + COM_MOKOSUITEFIELD_MENU_PARTS + COM_MOKOSUITEFIELD_MENU_CHECKLISTS + COM_MOKOSUITEFIELD_MENU_AGREEMENTS + COM_MOKOSUITEFIELD_MENU_DISPATCHES + + + src + tmpl + services + language + access.xml + config.xml + + + + + en-GB/com_mokosuitefield.ini + en-GB/com_mokosuitefield.sys.ini + + diff --git a/source/components/com_mokosuitefield/config.xml b/source/components/com_mokosuitefield/config.xml new file mode 100644 index 0000000..9de19da --- /dev/null +++ b/source/components/com_mokosuitefield/config.xml @@ -0,0 +1,19 @@ + + + +
+ +
+
diff --git a/source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.ini b/source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.ini new file mode 100644 index 0000000..3e6a97f --- /dev/null +++ b/source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.ini @@ -0,0 +1,14 @@ +; Copyright (C) 2026 Moko Consulting +; SPDX-License-Identifier: GPL-3.0-or-later +; Authored-by: Moko Consulting + +COM_MOKOSUITEFIELD="MokoSuite Field" +COM_MOKOSUITEFIELD_DESCRIPTION="Field service management for contractors." +COM_MOKOSUITEFIELD_MENU_DASHBOARD="Dashboard" +COM_MOKOSUITEFIELD_MENU_WORKORDERS="Work Orders" +COM_MOKOSUITEFIELD_MENU_TECHNICIANS="Technicians" +COM_MOKOSUITEFIELD_MENU_EQUIPMENT="Equipment" +COM_MOKOSUITEFIELD_MENU_PARTS="Parts" +COM_MOKOSUITEFIELD_MENU_CHECKLISTS="Checklists" +COM_MOKOSUITEFIELD_MENU_AGREEMENTS="PM Agreements" +COM_MOKOSUITEFIELD_MENU_DISPATCHES="Dispatches" diff --git a/source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.sys.ini b/source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.sys.ini new file mode 100644 index 0000000..3e6a97f --- /dev/null +++ b/source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.sys.ini @@ -0,0 +1,14 @@ +; Copyright (C) 2026 Moko Consulting +; SPDX-License-Identifier: GPL-3.0-or-later +; Authored-by: Moko Consulting + +COM_MOKOSUITEFIELD="MokoSuite Field" +COM_MOKOSUITEFIELD_DESCRIPTION="Field service management for contractors." +COM_MOKOSUITEFIELD_MENU_DASHBOARD="Dashboard" +COM_MOKOSUITEFIELD_MENU_WORKORDERS="Work Orders" +COM_MOKOSUITEFIELD_MENU_TECHNICIANS="Technicians" +COM_MOKOSUITEFIELD_MENU_EQUIPMENT="Equipment" +COM_MOKOSUITEFIELD_MENU_PARTS="Parts" +COM_MOKOSUITEFIELD_MENU_CHECKLISTS="Checklists" +COM_MOKOSUITEFIELD_MENU_AGREEMENTS="PM Agreements" +COM_MOKOSUITEFIELD_MENU_DISPATCHES="Dispatches" diff --git a/source/components/com_mokosuitefield/services/provider.php b/source/components/com_mokosuitefield/services/provider.php new file mode 100644 index 0000000..b341658 --- /dev/null +++ b/source/components/com_mokosuitefield/services/provider.php @@ -0,0 +1,41 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +\defined('_JEXEC') or die; + +use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface; +use Joomla\CMS\Extension\ComponentInterface; +use Joomla\CMS\Extension\MVCComponent; +use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory; +use Joomla\CMS\Extension\Service\Provider\MVCFactory; +use Joomla\CMS\Extension\Service\Provider\RouterFactory; +use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\CMS\Component\Router\RouterFactoryInterface; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoSuiteField')); + $container->registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoSuiteField')); + $container->registerServiceProvider(new RouterFactory('\\Moko\\Component\\MokoSuiteField')); + + $container->set( + ComponentInterface::class, + function (Container $container) { + $component = new MVCComponent(); + $component->setMVCFactory($container->get(MVCFactoryInterface::class)); + $component->setDispatcherFactory($container->get(ComponentDispatcherFactoryInterface::class)); + $component->setRouterFactory($container->get(RouterFactoryInterface::class)); + + return $component; + } + ); + } +}; diff --git a/source/components/com_mokosuitefield/src/Controller/DisplayController.php b/source/components/com_mokosuitefield/src/Controller/DisplayController.php new file mode 100644 index 0000000..fe41440 --- /dev/null +++ b/source/components/com_mokosuitefield/src/Controller/DisplayController.php @@ -0,0 +1,18 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +namespace Moko\Component\MokoSuiteField\Administrator\Controller; + +use Joomla\CMS\MVC\Controller\BaseController; + +\defined('_JEXEC') or die; + +class DisplayController extends BaseController +{ + protected $default_view = 'fielddashboard'; +} diff --git a/source/components/com_mokosuitefield/src/View/FieldAgreements/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldAgreements/HtmlView.php new file mode 100644 index 0000000..6450a43 --- /dev/null +++ b/source/components/com_mokosuitefield/src/View/FieldAgreements/HtmlView.php @@ -0,0 +1,24 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +namespace Moko\Component\MokoSuiteField\Administrator\View\FieldAgreements; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +\defined('_JEXEC') or die; + +class HtmlView extends BaseHtmlView +{ + public function display($tpl = null): void + { + ToolbarHelper::title('MokoSuite Field — PM Agreements', 'contract'); + + parent::display($tpl); + } +} diff --git a/source/components/com_mokosuitefield/src/View/FieldChecklists/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldChecklists/HtmlView.php new file mode 100644 index 0000000..9205936 --- /dev/null +++ b/source/components/com_mokosuitefield/src/View/FieldChecklists/HtmlView.php @@ -0,0 +1,24 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +namespace Moko\Component\MokoSuiteField\Administrator\View\FieldChecklists; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +\defined('_JEXEC') or die; + +class HtmlView extends BaseHtmlView +{ + public function display($tpl = null): void + { + ToolbarHelper::title('MokoSuite Field — Checklists', 'checklist'); + + parent::display($tpl); + } +} diff --git a/source/components/com_mokosuitefield/src/View/FieldDashboard/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldDashboard/HtmlView.php new file mode 100644 index 0000000..463d969 --- /dev/null +++ b/source/components/com_mokosuitefield/src/View/FieldDashboard/HtmlView.php @@ -0,0 +1,24 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +namespace Moko\Component\MokoSuiteField\Administrator\View\FieldDashboard; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +\defined('_JEXEC') or die; + +class HtmlView extends BaseHtmlView +{ + public function display($tpl = null): void + { + ToolbarHelper::title('MokoSuite Field — Dashboard', 'home'); + + parent::display($tpl); + } +} diff --git a/source/components/com_mokosuitefield/src/View/FieldDispatches/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldDispatches/HtmlView.php new file mode 100644 index 0000000..e53eca5 --- /dev/null +++ b/source/components/com_mokosuitefield/src/View/FieldDispatches/HtmlView.php @@ -0,0 +1,24 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +namespace Moko\Component\MokoSuiteField\Administrator\View\FieldDispatches; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +\defined('_JEXEC') or die; + +class HtmlView extends BaseHtmlView +{ + public function display($tpl = null): void + { + ToolbarHelper::title('MokoSuite Field — Dispatches', 'location'); + + parent::display($tpl); + } +} diff --git a/source/components/com_mokosuitefield/src/View/FieldEquipment/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldEquipment/HtmlView.php new file mode 100644 index 0000000..5c87dac --- /dev/null +++ b/source/components/com_mokosuitefield/src/View/FieldEquipment/HtmlView.php @@ -0,0 +1,24 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +namespace Moko\Component\MokoSuiteField\Administrator\View\FieldEquipment; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +\defined('_JEXEC') or die; + +class HtmlView extends BaseHtmlView +{ + public function display($tpl = null): void + { + ToolbarHelper::title('MokoSuite Field — Equipment', 'cogs'); + + parent::display($tpl); + } +} diff --git a/source/components/com_mokosuitefield/src/View/FieldParts/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldParts/HtmlView.php new file mode 100644 index 0000000..3de237f --- /dev/null +++ b/source/components/com_mokosuitefield/src/View/FieldParts/HtmlView.php @@ -0,0 +1,24 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +namespace Moko\Component\MokoSuiteField\Administrator\View\FieldParts; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +\defined('_JEXEC') or die; + +class HtmlView extends BaseHtmlView +{ + public function display($tpl = null): void + { + ToolbarHelper::title('MokoSuite Field — Parts', 'cube'); + + parent::display($tpl); + } +} diff --git a/source/components/com_mokosuitefield/src/View/FieldTechnicians/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldTechnicians/HtmlView.php new file mode 100644 index 0000000..565314d --- /dev/null +++ b/source/components/com_mokosuitefield/src/View/FieldTechnicians/HtmlView.php @@ -0,0 +1,24 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +namespace Moko\Component\MokoSuiteField\Administrator\View\FieldTechnicians; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +\defined('_JEXEC') or die; + +class HtmlView extends BaseHtmlView +{ + public function display($tpl = null): void + { + ToolbarHelper::title('MokoSuite Field — Technicians', 'users'); + + parent::display($tpl); + } +} diff --git a/source/components/com_mokosuitefield/src/View/FieldWorkorders/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldWorkorders/HtmlView.php new file mode 100644 index 0000000..309eba0 --- /dev/null +++ b/source/components/com_mokosuitefield/src/View/FieldWorkorders/HtmlView.php @@ -0,0 +1,24 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +namespace Moko\Component\MokoSuiteField\Administrator\View\FieldWorkorders; + +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +\defined('_JEXEC') or die; + +class HtmlView extends BaseHtmlView +{ + public function display($tpl = null): void + { + ToolbarHelper::title('MokoSuite Field — Work Orders', 'file-2'); + + parent::display($tpl); + } +} diff --git a/source/components/com_mokosuitefield/tmpl/fieldagreements/default.php b/source/components/com_mokosuitefield/tmpl/fieldagreements/default.php new file mode 100644 index 0000000..3b1fd45 --- /dev/null +++ b/source/components/com_mokosuitefield/tmpl/fieldagreements/default.php @@ -0,0 +1,13 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +\defined('_JEXEC') or die; +?> +
+

MokoSuite Field — PM Agreements

+
diff --git a/source/components/com_mokosuitefield/tmpl/fieldchecklists/default.php b/source/components/com_mokosuitefield/tmpl/fieldchecklists/default.php new file mode 100644 index 0000000..539738a --- /dev/null +++ b/source/components/com_mokosuitefield/tmpl/fieldchecklists/default.php @@ -0,0 +1,13 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +\defined('_JEXEC') or die; +?> +
+

MokoSuite Field — Checklists

+
diff --git a/source/components/com_mokosuitefield/tmpl/fielddashboard/default.php b/source/components/com_mokosuitefield/tmpl/fielddashboard/default.php new file mode 100644 index 0000000..6edbc0a --- /dev/null +++ b/source/components/com_mokosuitefield/tmpl/fielddashboard/default.php @@ -0,0 +1,13 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +\defined('_JEXEC') or die; +?> +
+

MokoSuite Field — Dashboard

+
diff --git a/source/components/com_mokosuitefield/tmpl/fielddispatches/default.php b/source/components/com_mokosuitefield/tmpl/fielddispatches/default.php new file mode 100644 index 0000000..a85727f --- /dev/null +++ b/source/components/com_mokosuitefield/tmpl/fielddispatches/default.php @@ -0,0 +1,13 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +\defined('_JEXEC') or die; +?> +
+

MokoSuite Field — Dispatches

+
diff --git a/source/components/com_mokosuitefield/tmpl/fieldequipment/default.php b/source/components/com_mokosuitefield/tmpl/fieldequipment/default.php new file mode 100644 index 0000000..d603886 --- /dev/null +++ b/source/components/com_mokosuitefield/tmpl/fieldequipment/default.php @@ -0,0 +1,13 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +\defined('_JEXEC') or die; +?> +
+

MokoSuite Field — Equipment

+
diff --git a/source/components/com_mokosuitefield/tmpl/fieldparts/default.php b/source/components/com_mokosuitefield/tmpl/fieldparts/default.php new file mode 100644 index 0000000..0442669 --- /dev/null +++ b/source/components/com_mokosuitefield/tmpl/fieldparts/default.php @@ -0,0 +1,13 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +\defined('_JEXEC') or die; +?> +
+

MokoSuite Field — Parts

+
diff --git a/source/components/com_mokosuitefield/tmpl/fieldtechnicians/default.php b/source/components/com_mokosuitefield/tmpl/fieldtechnicians/default.php new file mode 100644 index 0000000..42d3720 --- /dev/null +++ b/source/components/com_mokosuitefield/tmpl/fieldtechnicians/default.php @@ -0,0 +1,13 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +\defined('_JEXEC') or die; +?> +
+

MokoSuite Field — Technicians

+
diff --git a/source/components/com_mokosuitefield/tmpl/fieldworkorders/default.php b/source/components/com_mokosuitefield/tmpl/fieldworkorders/default.php new file mode 100644 index 0000000..27c139d --- /dev/null +++ b/source/components/com_mokosuitefield/tmpl/fieldworkorders/default.php @@ -0,0 +1,13 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +\defined('_JEXEC') or die; +?> +
+

MokoSuite Field — Work Orders

+
diff --git a/source/packages/com_mokosuitefield/admin/access.xml b/source/packages/com_mokosuitefield/admin/access.xml deleted file mode 100644 index 6f4f869..0000000 --- a/source/packages/com_mokosuitefield/admin/access.xml +++ /dev/null @@ -1,11 +0,0 @@ - - -
- - - - - - -
-
diff --git a/source/packages/com_mokosuitefield/admin/config.xml b/source/packages/com_mokosuitefield/admin/config.xml deleted file mode 100644 index b17bb28..0000000 --- a/source/packages/com_mokosuitefield/admin/config.xml +++ /dev/null @@ -1,25 +0,0 @@ - - -
- - - - - - - - -
-
- - -
-
- - - -
-
- -
-
diff --git a/source/packages/com_mokosuitefield/admin/services/provider.php b/source/packages/com_mokosuitefield/admin/services/provider.php deleted file mode 100644 index f056f8e..0000000 --- a/source/packages/com_mokosuitefield/admin/services/provider.php +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 0129bef..0000000 --- a/source/packages/com_mokosuitefield/admin/src/Controller/DisplayController.php +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 2f244ad..0000000 --- a/source/packages/com_mokosuitefield/admin/src/Model/EquipmentModel.php +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 7332a46..0000000 --- a/source/packages/com_mokosuitefield/admin/src/Model/EstimatesModel.php +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index c01a746..0000000 --- a/source/packages/com_mokosuitefield/admin/src/Model/ServiceAgreementsModel.php +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 6a32578..0000000 --- a/source/packages/com_mokosuitefield/admin/src/Model/TechniciansModel.php +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 0429c3a..0000000 --- a/source/packages/com_mokosuitefield/admin/src/Model/VehiclesModel.php +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index af03434..0000000 --- a/source/packages/com_mokosuitefield/admin/src/Model/WorkOrdersModel.php +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index c6b9eaf..0000000 --- a/source/packages/com_mokosuitefield/admin/src/View/Dashboard/HtmlView.php +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index e59bd25..0000000 --- a/source/packages/com_mokosuitefield/admin/src/View/Dispatch/HtmlView.php +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 5188748..0000000 --- a/source/packages/com_mokosuitefield/admin/src/View/Equipment/HtmlView.php +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index ecec27f..0000000 --- a/source/packages/com_mokosuitefield/admin/src/View/ServiceAgreements/HtmlView.php +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 9f6f903..0000000 --- a/source/packages/com_mokosuitefield/admin/src/View/Technicians/HtmlView.php +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 1ead5bc..0000000 --- a/source/packages/com_mokosuitefield/admin/src/View/Vehicles/HtmlView.php +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 06d57c4..0000000 --- a/source/packages/com_mokosuitefield/admin/src/View/WorkOrders/HtmlView.php +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index ecd06c8..0000000 --- a/source/packages/com_mokosuitefield/admin/tmpl/dashboard/default.php +++ /dev/null @@ -1,33 +0,0 @@ -stats; -$board = $this->dispatchBoard; -$unassigned = $this->unassigned; -$urgent = $this->urgent; -?> -
-
total_today; ?>
Today
-
urgent; ?>
Urgent
-
unassigned; ?>
Unassigned
-
en_route; ?>
En Route
-
on_site; ?>
On Site
-
completed; ?>
Done
-
-
-
Dispatch Board
- -
-
escape($tech->tech_name); ?>trade); ?>
-jobs)) : foreach ($tech->jobs as $job) : ?> -
escape($job->wo_number); ?> escape($job->customer_name ?? ''); ?> escape($job->city ?? ''); ?>
-
No jobs
-
- -
-
Unassigned ()
- -
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 deleted file mode 100644 index 4cf6a08..0000000 --- a/source/packages/com_mokosuitefield/admin/tmpl/dispatch/default.php +++ /dev/null @@ -1,10 +0,0 @@ -board;$s=$this->stats; -?> -
total_today; ?>
Today
urgent; ?>
Urgent
en_route; ?>
En Route
completed; ?>
Done
- -
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 deleted file mode 100644 index 12ec110..0000000 --- a/source/packages/com_mokosuitefield/admin/tmpl/equipment/default.php +++ /dev/null @@ -1,10 +0,0 @@ -equipment;$due=$this->serviceDue; -?> -
equipment due
- - - - -
TypeMake/ModelSerialOwnerLast 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 deleted file mode 100644 index 13e6bb3..0000000 --- a/source/packages/com_mokosuitefield/admin/tmpl/serviceagreements/default.php +++ /dev/null @@ -1,8 +0,0 @@ -agreements; $rev=$this->revenue; ?> -
active_agreements; ?>
Active Agreements
$annual_recurring,0); ?>
Annual Recurring
$monthly_recurring,0); ?>
Monthly
- - - - - -
AgreementCustomerTradeVisitsAnnualStatusExpires
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 deleted file mode 100644 index 8a25af9..0000000 --- a/source/packages/com_mokosuitefield/admin/tmpl/technicians/default.php +++ /dev/null @@ -1,7 +0,0 @@ -technicians; $statusColors=["available"=>"success","dispatched"=>"info","en_route"=>"warning","on_site"=>"primary","off_duty"=>"secondary","on_leave"=>"dark"]; ?> - - - - - -
TechTradeStatusPhoneVehicleLicenseJobs/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 deleted file mode 100644 index 4ec6984..0000000 --- a/source/packages/com_mokosuitefield/admin/tmpl/vehicles/default.php +++ /dev/null @@ -1,9 +0,0 @@ -vehicles; -?> - - - - -
VehicleMake/ModelAssigned ToMileageStatus
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 deleted file mode 100644 index 00b7a70..0000000 --- a/source/packages/com_mokosuitefield/admin/tmpl/workorders/default.php +++ /dev/null @@ -1,34 +0,0 @@ -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']; -?> -
-
-
-
-
-
-
- - - - - - - - - - - - - - -
WO#CustomerTradePriorityStatusTechnicianScheduledTotal
escape($wo->wo_number); ?>escape($wo->customer_name??''); ?>
escape($wo->city??''); ?>
trade); ?>priority); ?>status)); ?>escape($wo->tech_name??'Unassigned'); ?>scheduled_date?date('M j',strtotime($wo->scheduled_date)):'—'; ?>total>0?'$'.number_format((float)$wo->total,2):'—'; ?>
No work orders
- -
diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldEquipmentController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldEquipmentController.php deleted file mode 100644 index 881eece..0000000 --- a/source/packages/com_mokosuitefield/api/src/Controller/FieldEquipmentController.php +++ /dev/null @@ -1,178 +0,0 @@ -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 deleted file mode 100644 index 5e598c9..0000000 --- a/source/packages/com_mokosuitefield/api/src/Controller/FieldEstimatesController.php +++ /dev/null @@ -1,150 +0,0 @@ -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 deleted file mode 100644 index f433e4c..0000000 --- a/source/packages/com_mokosuitefield/api/src/Controller/FieldMobileController.php +++ /dev/null @@ -1,252 +0,0 @@ -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/FieldReportsController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldReportsController.php deleted file mode 100644 index e987004..0000000 --- a/source/packages/com_mokosuitefield/api/src/Controller/FieldReportsController.php +++ /dev/null @@ -1,122 +0,0 @@ -getIdentity(); - if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise('core.manage', 'com_mokosuitefield'))) { - http_response_code(403); - echo json_encode(['error' => 'Access denied']); - Factory::getApplication()->close(); - } - } - - public function techPerformance(): void - { - $this->requireAuth(); - $db = Factory::getContainer()->get(DatabaseInterface::class); - $input = Factory::getApplication()->getInput(); - $from = $input->getString('from', date('Y-m-01')); - $to = $input->getString('to', date('Y-m-d')); - - $db->setQuery($db->getQuery(true) - ->select('t.id, cd.name AS tech_name, t.trade') - ->select('COUNT(wo.id) AS total_jobs') - ->select('SUM(CASE WHEN wo.status = ' . $db->quote('completed') . ' THEN 1 ELSE 0 END) AS completed') - ->select('COALESCE(AVG(TIMESTAMPDIFF(MINUTE, wo.dispatched_at, wo.completed_at)), 0) AS avg_resolution_min') - ->select('COALESCE(SUM(te.hours), 0) AS total_hours') - ->from($db->quoteName('#__mokosuitefield_technicians', 't')) - ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') - ->join('LEFT', $db->quoteName('#__mokosuitefield_work_orders', 'wo') . ' ON wo.technician_id = t.id AND wo.scheduled_date BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)) - ->join('LEFT', $db->quoteName('#__mokosuitefield_time_entries', 'te') . ' ON te.wo_id = wo.id') - ->group('t.id') - ->order('completed DESC')); - - $techs = $db->loadObjectList() ?: []; - - foreach ($techs as &$tech) { - $tech->completion_rate = (int) $tech->total_jobs > 0 - ? round((int) $tech->completed / (int) $tech->total_jobs * 100, 1) : 0; - } - - $this->sendJson($techs); - } - - public function revenueByTrade(): void - { - $this->requireAuth(); - $db = Factory::getContainer()->get(DatabaseInterface::class); - $input = Factory::getApplication()->getInput(); - $from = $input->getString('from', date('Y-m-01')); - $to = $input->getString('to', date('Y-m-d')); - - $db->setQuery($db->getQuery(true) - ->select('wo.trade') - ->select('COUNT(wo.id) AS job_count') - ->select('COALESCE(SUM(i.total), 0) AS revenue') - ->select('COALESCE(AVG(i.total), 0) AS avg_invoice') - ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) - ->join('LEFT', $db->quoteName('#__mokosuite_crm_invoices', 'i') . ' ON i.id = wo.invoice_id') - ->where('wo.scheduled_date BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)) - ->where($db->quoteName('wo.status') . ' = ' . $db->quote('completed')) - ->group('wo.trade') - ->order('revenue DESC')); - - $this->sendJson($db->loadObjectList() ?: []); - } - - public function partsUsage(): void - { - $this->requireAuth(); - $parts = \Moko\Plugin\System\MokoSuiteField\Helper\PartsHelper::getCommonParts('', 30); - $lowStock = \Moko\Plugin\System\MokoSuiteField\Helper\PartsHelper::getLowStockParts(20); - - $this->sendJson(['top_used' => $parts, 'low_stock' => $lowStock]); - } - - public function slaCompliance(): void - { - $this->requireAuth(); - $db = Factory::getContainer()->get(DatabaseInterface::class); - $input = Factory::getApplication()->getInput(); - $from = $input->getString('from', date('Y-m-01')); - $to = $input->getString('to', date('Y-m-d')); - $slaHours = $input->getInt('sla_hours', 24); - - $db->setQuery($db->getQuery(true) - ->select('COUNT(*) AS total') - ->select('SUM(CASE WHEN TIMESTAMPDIFF(HOUR, wo.created, wo.completed_at) <= ' . (int) $slaHours . ' THEN 1 ELSE 0 END) AS within_sla') - ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) - ->where($db->quoteName('wo.status') . ' = ' . $db->quote('completed')) - ->where('wo.scheduled_date BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to))); - - $stats = $db->loadObject() ?: (object) ['total' => 0, 'within_sla' => 0]; - $stats->sla_pct = (int) $stats->total > 0 ? round((int) $stats->within_sla / (int) $stats->total * 100, 1) : 0; - $stats->sla_target_hours = $slaHours; - - $this->sendJson($stats); - } - - private function sendJson(mixed $data): void - { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); - Factory::getApplication()->close(); - } -} diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldSchedulingController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldSchedulingController.php deleted file mode 100644 index 75b125c..0000000 --- a/source/packages/com_mokosuitefield/api/src/Controller/FieldSchedulingController.php +++ /dev/null @@ -1,83 +0,0 @@ -getIdentity(); - if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) { - http_response_code(403); - echo json_encode(['error' => 'Access denied']); - Factory::getApplication()->close(); - } - } - - public function availableSlots(): void - { - $this->requireAuth(); - $input = Factory::getApplication()->getInput(); - - $slots = \Moko\Plugin\System\MokoSuiteField\Helper\SchedulingHelper::getAvailableSlots( - $input->getString('date', date('Y-m-d')), - $input->getString('trade', 'general'), - $input->getInt('duration', 60) - ); - - $this->sendJson($slots); - } - - public function bookSlot(): void - { - $this->requireAuth('core.create'); - $input = Factory::getApplication()->getInput(); - - $result = \Moko\Plugin\System\MokoSuiteField\Helper\SchedulingHelper::scheduleWorkOrder( - $input->getInt('wo_id', 0), - $input->getString('date', ''), - $input->getString('time', ''), - $input->getInt('tech_id', 0) ?: null - ); - - $this->sendJson(['success' => $result]); - } - - public function todaySchedule(): void - { - $this->requireAuth(); - $schedule = \Moko\Plugin\System\MokoSuiteField\Helper\SchedulingHelper::getTodaySchedule(); - $this->sendJson($schedule); - } - - public function techRoute(): void - { - $this->requireAuth(); - $techId = Factory::getApplication()->getInput()->getInt('tech_id', 0); - $date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d')); - - $route = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::getTechRoute($techId, $date); - $metrics = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::estimateRouteMetrics($techId, $date); - - $this->sendJson(['route' => $route, 'metrics' => $metrics]); - } - - private function sendJson(mixed $data): void - { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); - Factory::getApplication()->close(); - } -} diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldWorkOrderController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldWorkOrderController.php deleted file mode 100644 index d8fbe4c..0000000 --- a/source/packages/com_mokosuitefield/api/src/Controller/FieldWorkOrderController.php +++ /dev/null @@ -1,131 +0,0 @@ -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 deleted file mode 100644 index a22318a..0000000 --- a/source/packages/com_mokosuitefield/media/css/field.css +++ /dev/null @@ -1,9 +0,0 @@ -/* MokoSuite Field Service Styles */ -.dispatch-board .tech-card { border-left: 4px solid #198754; } -.dispatch-board .tech-card.dispatched { border-left-color: #0d6efd; } -.dispatch-board .tech-card.on-site { border-left-color: #ffc107; } -.wo-priority-emergency { background-color: rgba(220, 53, 69, 0.1) !important; } -.wo-priority-urgent { background-color: rgba(255, 193, 7, 0.08) !important; } -.tech-mobile .current-job { border: 2px solid #0d6efd; } -@media (max-width: 768px) { .tech-mobile .card { margin-bottom: 0.5rem; } } -@media print { .btn, .toolbar { display: none !important; } } diff --git a/source/packages/com_mokosuitefield/media/js/dispatch.js b/source/packages/com_mokosuitefield/media/js/dispatch.js deleted file mode 100644 index f0d9d81..0000000 --- a/source/packages/com_mokosuitefield/media/js/dispatch.js +++ /dev/null @@ -1,6 +0,0 @@ -document.addEventListener('DOMContentLoaded', function() { - // Auto-refresh dispatch board every 30 seconds - if (document.querySelector('.dispatch-board')) { - setInterval(function() { location.reload(); }, 30000); - } -}); diff --git a/source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php b/source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php deleted file mode 100644 index 9ca2166..0000000 --- a/source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 1aef77c..0000000 --- a/source/packages/com_mokosuitefield/site/src/View/CustomerPortal/HtmlView.php +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 6c6f8eb..0000000 --- a/source/packages/com_mokosuitefield/site/src/View/EstimateView/HtmlView.php +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 7695292..0000000 --- a/source/packages/com_mokosuitefield/site/src/View/TechMobile/HtmlView.php +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 237af91..0000000 --- a/source/packages/com_mokosuitefield/site/tmpl/bookservice/default.php +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 9f7da25..0000000 --- a/source/packages/com_mokosuitefield/site/tmpl/customerportal/default.php +++ /dev/null @@ -1,14 +0,0 @@ -contactId) return; -$active=$this->activeOrders;$history=$this->orderHistory;$next=$this->nextService; -?> -

My Service Portal

-
Next Service
trade); ?>scheduled_date)); ?>
-
Active Work Orders
- -
WO#ServiceStatus
escape($wo->wo_number); ?>trade); ?>status)); ?>
-
Service History
- -
WO#ServiceTotalDate
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 deleted file mode 100644 index 50fa071..0000000 --- a/source/packages/com_mokosuitefield/site/tmpl/estimateview/default.php +++ /dev/null @@ -1,96 +0,0 @@ -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.'; ?>

-
- - -
-
-

Service Estimate

- status); ?> -
-
-
-
- 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)); ?>

- - - - - - lineItems as $li) : ?> - - - - - - - - - - - - - -
DescriptionQtyUnit PriceTotal
description); ?>quantity; ?>$unit_price, 2); ?>$line_total, 2); ?>
Subtotal$
Tax$
Total$
-
-
- - status === 'sent') : ?> -
-
-
- - -
-
-
Approve Estimate
-
- - -
- -
-
-
-
-
-
- - -
-
-
Decline Estimate
-
- - -
- -
-
-
-
-
- -
diff --git a/source/packages/com_mokosuitefield/site/tmpl/techmobile/default.php b/source/packages/com_mokosuitefield/site/tmpl/techmobile/default.php deleted file mode 100644 index 691ee15..0000000 --- a/source/packages/com_mokosuitefield/site/tmpl/techmobile/default.php +++ /dev/null @@ -1,56 +0,0 @@ -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)); ?> -
- - -
-
Current Job: escape($current->wo_number); ?>
-
-
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 deleted file mode 100644 index c507f86..0000000 --- a/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 26bf5c4..0000000 --- a/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index e0a9d8d..0000000 --- a/source/packages/plg_system_mokosuitefield/sql/install.mysql.sql +++ /dev/null @@ -1,332 +0,0 @@ --- --- MokoSuite Field Service Tables --- - --- ============================================================ --- Technicians — extends CRM contacts / HRM employees --- ============================================================ -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_technicians` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `contact_id` INT NOT NULL COMMENT 'FK to #__contact_details', - `employee_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to HRM employees if installed', - `tech_number` VARCHAR(20) NOT NULL DEFAULT '', - `status` ENUM('available','dispatched','en_route','on_site','off_duty','on_leave') NOT NULL DEFAULT 'available', - `trade` ENUM('general','plumbing','electrical','hvac','appliance','carpentry','painting','roofing','landscaping','pest_control','locksmith','multi_trade') NOT NULL DEFAULT 'general', - `license_number` VARCHAR(100) DEFAULT NULL COMMENT 'Trade license (e.g., master plumber)', - `license_expiry` DATE DEFAULT NULL, - `certifications` JSON DEFAULT NULL COMMENT '["EPA 608","backflow certified","journeyman electrician"]', - `skills` JSON DEFAULT NULL, - `hourly_rate` DECIMAL(10,2) DEFAULT NULL, - `overtime_rate` DECIMAL(10,2) DEFAULT NULL, - `max_daily_jobs` INT UNSIGNED NOT NULL DEFAULT 8, - `service_radius_miles` INT UNSIGNED NOT NULL DEFAULT 30, - `home_zip` VARCHAR(10) DEFAULT NULL, - `home_lat` DECIMAL(10,7) DEFAULT NULL, - `home_lng` DECIMAL(10,7) DEFAULT NULL, - `current_lat` DECIMAL(10,7) DEFAULT NULL, - `current_lng` DECIMAL(10,7) DEFAULT NULL, - `last_location_update` DATETIME DEFAULT NULL, - `vehicle_id` INT UNSIGNED DEFAULT NULL, - `notes` TEXT, - `created` DATETIME NOT NULL, - `modified` DATETIME DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `idx_contact` (`contact_id`), - KEY `idx_status` (`status`), - KEY `idx_trade` (`trade`), - KEY `idx_vehicle` (`vehicle_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- ============================================================ --- Service Locations — customer property/site records --- ============================================================ -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_locations` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `contact_id` INT NOT NULL COMMENT 'FK to CRM contact (property owner)', - `name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'e.g., "Main Office", "123 Oak St Residence"', - `address` TEXT NOT NULL, - `city` VARCHAR(100) NOT NULL DEFAULT '', - `state` VARCHAR(50) NOT NULL DEFAULT '', - `zip` VARCHAR(20) NOT NULL DEFAULT '', - `latitude` DECIMAL(10,7) DEFAULT NULL, - `longitude` DECIMAL(10,7) DEFAULT NULL, - `property_type` ENUM('residential','commercial','industrial','multi_family','government','other') NOT NULL DEFAULT 'residential', - `access_notes` TEXT COMMENT 'Gate codes, parking, pet warnings, key location', - `equipment_notes` TEXT COMMENT 'Known equipment at this location', - `service_history_count` INT UNSIGNED NOT NULL DEFAULT 0, - `last_service_date` DATE DEFAULT NULL, - `created` DATETIME NOT NULL, - `modified` DATETIME DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `idx_contact` (`contact_id`), - KEY `idx_zip` (`zip`), - KEY `idx_type` (`property_type`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- ============================================================ --- Work Orders — the core job record --- ============================================================ -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_work_orders` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `wo_number` VARCHAR(30) NOT NULL DEFAULT '', - `contact_id` INT NOT NULL COMMENT 'Customer', - `location_id` INT UNSIGNED DEFAULT NULL, - `technician_id` INT UNSIGNED DEFAULT NULL, - `trade` ENUM('general','plumbing','electrical','hvac','appliance','carpentry','painting','roofing','landscaping','pest_control','locksmith','multi_trade') NOT NULL DEFAULT 'general', - `priority` ENUM('emergency','urgent','high','normal','low','scheduled') NOT NULL DEFAULT 'normal', - `status` ENUM('new','dispatched','en_route','on_site','in_progress','parts_needed','on_hold','completed','invoiced','cancelled') NOT NULL DEFAULT 'new', - `category` VARCHAR(100) DEFAULT NULL COMMENT 'e.g., "water heater", "panel upgrade", "AC repair"', - `description` TEXT NOT NULL, - `customer_po` VARCHAR(100) DEFAULT NULL COMMENT 'Customer PO number', - `scheduled_date` DATE DEFAULT NULL, - `scheduled_time_start` TIME DEFAULT NULL, - `scheduled_time_end` TIME DEFAULT NULL, - `actual_arrival` DATETIME DEFAULT NULL, - `actual_departure` DATETIME DEFAULT NULL, - `work_performed` TEXT, - `diagnosis` TEXT, - `parts_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `labor_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `tax` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `payment_status` ENUM('unpaid','partial','paid','waived') NOT NULL DEFAULT 'unpaid', - `payment_method` VARCHAR(50) DEFAULT NULL, - `customer_signature` TEXT COMMENT 'Base64 signature data', - `customer_signed_at` DATETIME DEFAULT NULL, - `service_agreement_id` INT UNSIGNED DEFAULT NULL, - `invoice_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM invoices', - `deal_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM deals', - `source` ENUM('phone','website','walk_in','referral','service_agreement','emergency','recurring') NOT NULL DEFAULT 'phone', - `created_by` INT NOT NULL DEFAULT 0, - `created` DATETIME NOT NULL, - `modified` DATETIME DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `idx_wo_number` (`wo_number`), - KEY `idx_contact` (`contact_id`), - KEY `idx_location` (`location_id`), - KEY `idx_tech` (`technician_id`), - KEY `idx_status` (`status`), - KEY `idx_priority` (`priority`), - KEY `idx_trade` (`trade`), - KEY `idx_scheduled` (`scheduled_date`), - KEY `idx_agreement` (`service_agreement_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- ============================================================ --- Work Order Line Items — parts and labor --- ============================================================ -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_wo_items` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `work_order_id` INT UNSIGNED NOT NULL, - `item_type` ENUM('labor','part','material','flat_rate','discount','permit','disposal') NOT NULL DEFAULT 'labor', - `product_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM products for parts', - `description` VARCHAR(500) NOT NULL, - `quantity` DECIMAL(10,2) NOT NULL DEFAULT 1.00, - `unit_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `line_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `taxable` TINYINT NOT NULL DEFAULT 1, - `from_truck_stock` TINYINT NOT NULL DEFAULT 0 COMMENT 'Taken from tech truck inventory', - `sort_order` INT NOT NULL DEFAULT 0, - PRIMARY KEY (`id`), - KEY `idx_wo` (`work_order_id`), - KEY `idx_type` (`item_type`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- ============================================================ --- Work Order Photos — before/after, damage, equipment --- ============================================================ -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_wo_photos` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `work_order_id` INT UNSIGNED NOT NULL, - `file_path` VARCHAR(500) NOT NULL, - `thumbnail_path` VARCHAR(500) DEFAULT NULL, - `photo_type` ENUM('before','during','after','damage','equipment','permit','other') NOT NULL DEFAULT 'other', - `caption` VARCHAR(500) DEFAULT NULL, - `latitude` DECIMAL(10,7) DEFAULT NULL, - `longitude` DECIMAL(10,7) DEFAULT NULL, - `taken_at` DATETIME DEFAULT NULL, - `uploaded_by` INT NOT NULL DEFAULT 0, - `created` DATETIME NOT NULL, - PRIMARY KEY (`id`), - KEY `idx_wo` (`work_order_id`), - KEY `idx_type` (`photo_type`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- ============================================================ --- Service Agreements — recurring maintenance contracts --- ============================================================ -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_agreements` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `contact_id` INT NOT NULL, - `location_id` INT UNSIGNED DEFAULT NULL, - `agreement_number` VARCHAR(30) NOT NULL DEFAULT '', - `title` VARCHAR(255) NOT NULL, - `trade` ENUM('general','plumbing','electrical','hvac','appliance','multi_trade') NOT NULL DEFAULT 'general', - `agreement_type` ENUM('preventive','full_service','parts_only','labor_only','priority_response') NOT NULL DEFAULT 'preventive', - `visits_per_year` INT UNSIGNED NOT NULL DEFAULT 2, - `visits_used` INT UNSIGNED NOT NULL DEFAULT 0, - `annual_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `billing_frequency` ENUM('monthly','quarterly','semi_annual','annual') NOT NULL DEFAULT 'annual', - `start_date` DATE NOT NULL, - `end_date` DATE DEFAULT NULL, - `auto_renew` TINYINT NOT NULL DEFAULT 1, - `sla_response_hours` INT UNSIGNED DEFAULT NULL COMMENT 'Guaranteed response time', - `parts_discount_pct` DECIMAL(5,2) NOT NULL DEFAULT 0.00, - `labor_discount_pct` DECIMAL(5,2) NOT NULL DEFAULT 0.00, - `status` ENUM('active','expired','cancelled','pending_renewal') NOT NULL DEFAULT 'active', - `equipment_covered` JSON DEFAULT NULL COMMENT 'List of equipment IDs covered', - `notes` TEXT, - `created` DATETIME NOT NULL, - `modified` DATETIME DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `idx_number` (`agreement_number`), - KEY `idx_contact` (`contact_id`), - KEY `idx_location` (`location_id`), - KEY `idx_status` (`status`), - KEY `idx_end` (`end_date`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- ============================================================ --- Equipment — customer equipment tracked for service history --- ============================================================ -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_equipment` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `location_id` INT UNSIGNED NOT NULL, - `contact_id` INT NOT NULL, - `equipment_type` ENUM('water_heater','furnace','ac_unit','heat_pump','boiler','electrical_panel','generator','sump_pump','water_softener','tankless_heater','mini_split','rooftop_unit','compressor','chiller','other') NOT NULL DEFAULT 'other', - `make` VARCHAR(100) DEFAULT NULL, - `model` VARCHAR(100) DEFAULT NULL, - `serial_number` VARCHAR(100) DEFAULT NULL, - `install_date` DATE DEFAULT NULL, - `warranty_expiry` DATE DEFAULT NULL, - `last_service_date` DATE DEFAULT NULL, - `next_service_date` DATE DEFAULT NULL, - `condition` ENUM('excellent','good','fair','poor','needs_replacement') DEFAULT NULL, - `location_detail` VARCHAR(255) DEFAULT NULL COMMENT 'e.g., "basement", "roof", "garage"', - `refrigerant_type` VARCHAR(20) DEFAULT NULL COMMENT 'HVAC: R-410A, R-22, etc.', - `capacity` VARCHAR(50) DEFAULT NULL COMMENT 'e.g., "50 gal", "3 ton", "200 amp"', - `fuel_type` VARCHAR(20) DEFAULT NULL COMMENT 'gas, electric, oil, propane', - `notes` TEXT, - `photo_path` VARCHAR(500) DEFAULT NULL, - `qr_code` VARCHAR(100) DEFAULT NULL COMMENT 'QR code on equipment sticker for quick lookup', - `created` DATETIME NOT NULL, - `modified` DATETIME DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `idx_location` (`location_id`), - KEY `idx_contact` (`contact_id`), - KEY `idx_type` (`equipment_type`), - KEY `idx_serial` (`serial_number`), - KEY `idx_qr` (`qr_code`), - KEY `idx_next_service` (`next_service_date`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- ============================================================ --- Vehicles — service fleet tracking --- ============================================================ -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_vehicles` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `vehicle_number` VARCHAR(20) NOT NULL, - `make` VARCHAR(50) DEFAULT NULL, - `model` VARCHAR(50) DEFAULT NULL, - `year` INT UNSIGNED DEFAULT NULL, - `vin` VARCHAR(17) DEFAULT NULL, - `license_plate` VARCHAR(20) DEFAULT NULL, - `assigned_tech_id` INT UNSIGNED DEFAULT NULL, - `status` ENUM('active','maintenance','retired') NOT NULL DEFAULT 'active', - `mileage` INT UNSIGNED DEFAULT NULL, - `last_inspection` DATE DEFAULT NULL, - `next_inspection` DATE DEFAULT NULL, - `insurance_expiry` DATE DEFAULT NULL, - `gps_device_id` VARCHAR(100) DEFAULT NULL, - `notes` TEXT, - `created` DATETIME NOT NULL, - PRIMARY KEY (`id`), - KEY `idx_tech` (`assigned_tech_id`), - KEY `idx_status` (`status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- ============================================================ --- Truck Stock — parts inventory per vehicle --- ============================================================ -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_truck_stock` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `vehicle_id` INT UNSIGNED NOT NULL, - `product_id` INT UNSIGNED NOT NULL COMMENT 'FK to CRM products', - `quantity` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `min_quantity` DECIMAL(10,2) NOT NULL DEFAULT 1.00 COMMENT 'Reorder point', - `last_restocked` DATE DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `idx_vehicle_product` (`vehicle_id`, `product_id`), - KEY `idx_vehicle` (`vehicle_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- ============================================================ --- Dispatch Log — assignment and routing history --- ============================================================ -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_dispatch_log` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `work_order_id` INT UNSIGNED NOT NULL, - `technician_id` INT UNSIGNED NOT NULL, - `action` ENUM('assigned','accepted','rejected','en_route','arrived','completed','reassigned','cancelled') NOT NULL, - `notes` TEXT, - `latitude` DECIMAL(10,7) DEFAULT NULL, - `longitude` DECIMAL(10,7) DEFAULT NULL, - `created_by` INT NOT NULL DEFAULT 0, - `created` DATETIME NOT NULL, - PRIMARY KEY (`id`), - KEY `idx_wo` (`work_order_id`), - KEY `idx_tech` (`technician_id`), - KEY `idx_action` (`action`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- ============================================================ --- Estimates — on-site quoting --- ============================================================ -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_estimates` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `work_order_id` INT UNSIGNED DEFAULT NULL COMMENT 'Generated from a WO inspection', - `contact_id` INT NOT NULL, - `location_id` INT UNSIGNED DEFAULT NULL, - `estimate_number` VARCHAR(30) NOT NULL DEFAULT '', - `title` VARCHAR(255) NOT NULL, - `description` TEXT, - `parts_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `labor_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `tax` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `status` ENUM('draft','sent','viewed','accepted','declined','expired','converted') NOT NULL DEFAULT 'draft', - `valid_days` INT UNSIGNED NOT NULL DEFAULT 30, - `token` VARCHAR(64) DEFAULT NULL COMMENT 'Public view/accept token', - `accepted_at` DATETIME DEFAULT NULL, - `customer_signature` TEXT, - `converted_wo_id` INT UNSIGNED DEFAULT NULL COMMENT 'WO created from accepted estimate', - `created_by` INT NOT NULL DEFAULT 0, - `created` DATETIME NOT NULL, - `modified` DATETIME DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `idx_number` (`estimate_number`), - KEY `idx_contact` (`contact_id`), - KEY `idx_wo` (`work_order_id`), - KEY `idx_status` (`status`), - KEY `idx_token` (`token`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- ============================================================ --- Time Entries — tech labor tracking per work order --- ============================================================ -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_time_entries` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `work_order_id` INT UNSIGNED NOT NULL, - `technician_id` INT UNSIGNED NOT NULL, - `start_time` DATETIME NOT NULL, - `end_time` DATETIME DEFAULT NULL, - `hours` DECIMAL(5,2) DEFAULT NULL, - `is_overtime` TINYINT NOT NULL DEFAULT 0, - `is_travel` TINYINT NOT NULL DEFAULT 0, - `rate` DECIMAL(10,2) DEFAULT NULL, - `notes` TEXT, - `created` DATETIME NOT NULL, - PRIMARY KEY (`id`), - KEY `idx_wo` (`work_order_id`), - KEY `idx_tech` (`technician_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql b/source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql deleted file mode 100644 index e804a60..0000000 --- a/source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql +++ /dev/null @@ -1,12 +0,0 @@ -DROP TABLE IF EXISTS `#__mokosuitefield_time_entries`; -DROP TABLE IF EXISTS `#__mokosuitefield_dispatch_log`; -DROP TABLE IF EXISTS `#__mokosuitefield_estimates`; -DROP TABLE IF EXISTS `#__mokosuitefield_truck_stock`; -DROP TABLE IF EXISTS `#__mokosuitefield_vehicles`; -DROP TABLE IF EXISTS `#__mokosuitefield_wo_photos`; -DROP TABLE IF EXISTS `#__mokosuitefield_wo_items`; -DROP TABLE IF EXISTS `#__mokosuitefield_work_orders`; -DROP TABLE IF EXISTS `#__mokosuitefield_agreements`; -DROP TABLE IF EXISTS `#__mokosuitefield_equipment`; -DROP TABLE IF EXISTS `#__mokosuitefield_locations`; -DROP TABLE IF EXISTS `#__mokosuitefield_technicians`; diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/CustomerFeedbackHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/CustomerFeedbackHelper.php deleted file mode 100644 index 21df3a5..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/CustomerFeedbackHelper.php +++ /dev/null @@ -1,132 +0,0 @@ -get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('wo.id, wo.wo_number, wo.contact_id, cd.name AS customer_name, cd.email_to, cd.telephone') - ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) - ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') - ->where('wo.id = ' . (int) $woId)); - $wo = $db->loadObject(); - - if (!$wo || !$wo->email_to) { - return (object) ['success' => false, 'error' => 'No email for feedback request']; - } - - // Generate unique feedback token - $token = bin2hex(random_bytes(16)); - - $request = (object) [ - 'wo_id' => $woId, - 'contact_id' => $wo->contact_id, - 'token' => $token, - 'status' => 'sent', - 'sent_at' => Factory::getDate()->toSql(), - ]; - $db->insertObject('#__mokosuitefield_feedback_requests', $request, 'id'); - - return (object) ['success' => true, 'token' => $token, 'email' => $wo->email_to]; - } - - /** - * Submit feedback (called from public survey page via token). - */ - public static function submitFeedback(string $token, int $rating, int $npsScore, string $comments = ''): object - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - // Validate inputs before DB access - $rating = max(1, min(5, $rating)); - $npsScore = max(0, min(10, $npsScore)); - - $filter = \Joomla\Filter\InputFilter::getInstance(); - $comments = $filter->clean($comments, 'STRING'); - - $db->transactionStart(); - try { - // Lock the request row to prevent race condition on token reuse - $db->setQuery('SELECT id, wo_id, contact_id, status FROM #__mokosuitefield_feedback_requests WHERE ' - . $db->quoteName('token') . ' = ' . $db->quote($token) . ' FOR UPDATE'); - $request = $db->loadObject(); - - if (!$request) { - $db->transactionRollback(); - return (object) ['success' => false, 'error' => 'Invalid feedback link']; - } - if ($request->status === 'completed') { - $db->transactionRollback(); - return (object) ['success' => false, 'error' => 'Feedback already submitted']; - } - - $feedback = (object) [ - 'request_id' => $request->id, - 'wo_id' => $request->wo_id, - 'contact_id' => $request->contact_id, - 'rating' => $rating, - 'nps_score' => $npsScore, - 'comments' => $comments, - 'submitted_at' => Factory::getDate()->toSql(), - ]; - $db->insertObject('#__mokosuitefield_feedback', $feedback); - - $db->setQuery($db->getQuery(true) - ->update('#__mokosuitefield_feedback_requests') - ->set($db->quoteName('status') . ' = ' . $db->quote('completed')) - ->where('id = ' . (int) $request->id)); - $db->execute(); - - $db->transactionCommit(); - } catch (\Throwable $e) { - $db->transactionRollback(); - // Don't leak internal errors to public users - return (object) ['success' => false, 'error' => 'Unable to save feedback. Please try again.']; - } - - return (object) ['success' => true, 'rating' => $rating, 'nps' => $npsScore]; - } - - /** - * Get NPS score and satisfaction summary. - */ - public static function getSatisfactionSummary(string $from = '', string $to = ''): object - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - $from = $from ?: date('Y-01-01'); - $to = $to ?: date('Y-m-d'); - - $db->setQuery($db->getQuery(true) - ->select('COUNT(*) AS total_responses') - ->select('COALESCE(AVG(rating), 0) AS avg_rating') - ->select('COALESCE(AVG(nps_score), 0) AS avg_nps') - ->select('SUM(CASE WHEN nps_score >= 9 THEN 1 ELSE 0 END) AS promoters') - ->select('SUM(CASE WHEN nps_score BETWEEN 7 AND 8 THEN 1 ELSE 0 END) AS passives') - ->select('SUM(CASE WHEN nps_score <= 6 THEN 1 ELSE 0 END) AS detractors') - ->from('#__mokosuitefield_feedback') - ->where('DATE(submitted_at) BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to))); - - $stats = $db->loadObject() ?: (object) ['total_responses' => 0, 'avg_rating' => 0, 'avg_nps' => 0, 'promoters' => 0, 'passives' => 0, 'detractors' => 0]; - - $total = (int) $stats->total_responses; - $stats->nps_score = $total > 0 - ? round(((int) $stats->promoters - (int) $stats->detractors) / $total * 100) - : 0; - - return $stats; - } -} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/CustomerSatisfactionHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/CustomerSatisfactionHelper.php deleted file mode 100644 index 80bf6f9..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/CustomerSatisfactionHelper.php +++ /dev/null @@ -1,118 +0,0 @@ - 5) { - throw new \InvalidArgumentException('Rating must be 1-5.'); - } - - $db = Factory::getContainer()->get(DatabaseInterface::class); - - // Prevent duplicate surveys per work order - $db->setQuery($db->getQuery(true) - ->select('id') - ->from('#__mokosuitefield_surveys') - ->where('work_order_id = ' . (int) $workOrderId) - ->where('contact_id = ' . (int) $contactId)); - - if ($db->loadResult()) { - return (object) ['success' => false, 'error' => 'Survey already submitted for this work order']; - } - - $filter = \Joomla\Filter\InputFilter::getInstance(); - - $survey = (object) [ - 'work_order_id' => $workOrderId, - 'contact_id' => $contactId, - 'rating' => $rating, - 'comment' => $comment !== null ? $filter->clean($comment, 'STRING') : null, - 'nps_score' => $rating >= 4 ? 'promoter' : ($rating >= 3 ? 'passive' : 'detractor'), - 'created_at' => Factory::getDate()->toSql(), - ]; - - $db->insertObject('#__mokosuitefield_surveys', $survey, 'id'); - - return (object) ['success' => true, 'survey_id' => (int) $survey->id]; - } - - /** - * Get NPS (Net Promoter Score) for a period. - */ - public static function getNps(string $from = '', string $to = ''): object - { - $from = $from ?: date('Y-01-01'); - $to = $to ?: date('Y-m-d'); - - if (!\DateTime::createFromFormat('Y-m-d', $from) || !\DateTime::createFromFormat('Y-m-d', $to)) { - throw new \InvalidArgumentException('Date parameters must be Y-m-d format.'); - } - - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('COUNT(*) AS total_responses') - ->select('SUM(CASE WHEN rating >= 4 THEN 1 ELSE 0 END) AS promoters') - ->select('SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END) AS passives') - ->select('SUM(CASE WHEN rating <= 2 THEN 1 ELSE 0 END) AS detractors') - ->select('AVG(rating) AS avg_rating') - ->from('#__mokosuitefield_surveys') - ->where('DATE(created_at) BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to))); - - $stats = $db->loadObject(); - $total = (int) ($stats->total_responses ?? 0); - $promoterPct = $total > 0 ? (int) $stats->promoters / $total * 100 : 0; - $detractorPct = $total > 0 ? (int) $stats->detractors / $total * 100 : 0; - - return (object) [ - 'nps' => round($promoterPct - $detractorPct), - 'total_responses' => $total, - 'promoters' => (int) ($stats->promoters ?? 0), - 'passives' => (int) ($stats->passives ?? 0), - 'detractors' => (int) ($stats->detractors ?? 0), - 'avg_rating' => round((float) ($stats->avg_rating ?? 0), 1), - ]; - } - - /** - * Get technician satisfaction rankings. - */ - public static function getTechnicianRankings(int $limit = 20): array - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('t.id AS tech_id, cd.name AS tech_name') - ->select('COUNT(s.id) AS survey_count') - ->select('AVG(s.rating) AS avg_rating') - ->select('SUM(CASE WHEN s.rating >= 4 THEN 1 ELSE 0 END) AS five_star_count') - ->from($db->quoteName('#__mokosuitefield_surveys', 's')) - ->join('INNER', $db->quoteName('#__mokosuitefield_work_orders', 'wo') . ' ON wo.id = s.work_order_id') - ->join('INNER', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id') - ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') - ->group('t.id, cd.name') - ->having('COUNT(s.id) >= 3') - ->order('avg_rating DESC'), 0, min(max(1, $limit), 100)); - - $results = $db->loadObjectList() ?: []; - - foreach ($results as &$r) { - $r->avg_rating = round((float) $r->avg_rating, 1); - } - - return $results; - } -} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php deleted file mode 100644 index 487b566..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index 1d56d11..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/EquipmentHelper.php +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 3ef628c..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/EstimateHelper.php +++ /dev/null @@ -1,83 +0,0 @@ -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/GpsTrackingHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/GpsTrackingHelper.php deleted file mode 100644 index 99c8cf8..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/GpsTrackingHelper.php +++ /dev/null @@ -1,112 +0,0 @@ - 90 || $longitude < -180 || $longitude > 180) { - throw new \InvalidArgumentException('Invalid GPS coordinates.'); - } - - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $ping = (object) [ - 'vehicle_id' => $vehicleId, - 'latitude' => round($latitude, 6), - 'longitude' => round($longitude, 6), - 'speed_mph' => max(0, round($speed, 1)), - 'recorded_at'=> Factory::getDate()->toSql(), - ]; - - $db->insertObject('#__mokosuitefield_gps_pings', $ping); - - return true; - } - - /** - * Get latest position for all active vehicles. - */ - public static function getFleetPositions(): array - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('v.id AS vehicle_id, v.name AS vehicle_name, v.license_plate') - ->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at') - ->select('cd.name AS assigned_tech') - ->from($db->quoteName('#__mokosuitefield_vehicles', 'v')) - ->join('LEFT', '(SELECT g1.* FROM #__mokosuitefield_gps_pings g1' - . ' INNER JOIN (SELECT vehicle_id, MAX(recorded_at) AS max_at' - . ' FROM #__mokosuitefield_gps_pings GROUP BY vehicle_id) g2' - . ' ON g1.vehicle_id = g2.vehicle_id AND g1.recorded_at = g2.max_at) AS gp' - . ' ON gp.vehicle_id = v.id') - ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.vehicle_id = v.id') - ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') - ->where($db->quoteName('v.status') . ' = ' . $db->quote('active')) - ->order('v.name ASC')); - - return $db->loadObjectList() ?: []; - } - - /** - * Get drive history for a vehicle on a specific date. - */ - public static function getDriveHistory(int $vehicleId, string $date = ''): array - { - $date = $date ?: date('Y-m-d'); - - if (!\DateTime::createFromFormat('Y-m-d', $date)) { - throw new \InvalidArgumentException('Date must be Y-m-d format.'); - } - - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at') - ->from($db->quoteName('#__mokosuitefield_gps_pings', 'gp')) - ->where('gp.vehicle_id = ' . (int) $vehicleId) - ->where('DATE(gp.recorded_at) = ' . $db->quote($date)) - ->order('gp.recorded_at ASC')); - - return $db->loadObjectList() ?: []; - } - - /** - * Get vehicles currently exceeding speed threshold. - */ - public static function getSpeeding(float $thresholdMph = 70): array - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('v.id AS vehicle_id, v.name AS vehicle_name, v.license_plate') - ->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at') - ->select('cd.name AS assigned_tech') - ->from($db->quoteName('#__mokosuitefield_vehicles', 'v')) - ->join('INNER', '(SELECT g1.* FROM #__mokosuitefield_gps_pings g1' - . ' INNER JOIN (SELECT vehicle_id, MAX(recorded_at) AS max_at' - . ' FROM #__mokosuitefield_gps_pings GROUP BY vehicle_id) g2' - . ' ON g1.vehicle_id = g2.vehicle_id AND g1.recorded_at = g2.max_at) AS gp' - . ' ON gp.vehicle_id = v.id') - ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.vehicle_id = v.id') - ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') - ->where($db->quoteName('v.status') . ' = ' . $db->quote('active')) - ->where('gp.speed_mph > ' . (float) $thresholdMph) - ->where('gp.recorded_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE)') - ->order('gp.speed_mph DESC')); - - return $db->loadObjectList() ?: []; - } -} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/InvoiceHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/InvoiceHelper.php deleted file mode 100644 index 83e51b7..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/InvoiceHelper.php +++ /dev/null @@ -1,151 +0,0 @@ -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/PartsHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/PartsHelper.php deleted file mode 100644 index 38532a0..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/PartsHelper.php +++ /dev/null @@ -1,107 +0,0 @@ -get(DatabaseInterface::class); - - $query = $db->getQuery(true) - ->select('p.id, p.title AS name, p.sku, p.price, p.cost_price, p.stock_quantity') - ->select('COUNT(wi.id) AS usage_count') - ->from($db->quoteName('#__mokosuite_crm_products', 'p')) - ->join('LEFT', $db->quoteName('#__mokosuitefield_wo_items', 'wi') . ' ON wi.product_id = p.id') - ->where($db->quoteName('p.published') . ' = 1') - ->group('p.id') - ->order('usage_count DESC'); - - if ($trade) { - $query->join('LEFT', $db->quoteName('#__mokosuitefield_work_orders', 'wo') . ' ON wo.id = wi.wo_id') - ->where($db->quoteName('wo.trade') . ' = ' . $db->quote($trade)); - } - - $db->setQuery($query, 0, $limit); - return $db->loadObjectList() ?: []; - } - - /** - * Record part usage on a work order. - */ - public static function usePart(int $woId, int $productId, int $quantity, ?float $unitPrice = null): int - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - if ($unitPrice === null) { - $db->setQuery($db->getQuery(true)->select('price')->from('#__mokosuite_crm_products')->where('id = ' . (int) $productId)); - $unitPrice = (float) $db->loadResult(); - } - - $item = (object) [ - 'wo_id' => $woId, - 'product_id' => $productId, - 'quantity' => $quantity, - 'unit_price' => $unitPrice, - 'line_total' => round($unitPrice * $quantity, 2), - 'created' => Factory::getDate()->toSql(), - ]; - - $db->insertObject('#__mokosuitefield_wo_items', $item, 'id'); - - // Deduct from stock - $db->setQuery($db->getQuery(true) - ->update('#__mokosuite_crm_products') - ->set('stock_quantity = stock_quantity - ' . (int) $quantity) - ->where('id = ' . (int) $productId)); - $db->execute(); - - return (int) $item->id; - } - - /** - * Get parts that are low on stock and frequently used in field service. - */ - public static function getLowStockParts(int $limit = 20): array - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('p.id, p.title AS name, p.sku, p.stock_quantity, p.reorder_point, p.cost_price') - ->select('(SELECT COUNT(*) FROM #__mokosuitefield_wo_items wi WHERE wi.product_id = p.id) AS field_usage') - ->from($db->quoteName('#__mokosuite_crm_products', 'p')) - ->where($db->quoteName('p.stock_quantity') . ' <= ' . $db->quoteName('p.reorder_point')) - ->where($db->quoteName('p.reorder_point') . ' > 0') - ->where('p.id IN (SELECT DISTINCT product_id FROM #__mokosuitefield_wo_items)') - ->order('(p.reorder_point - p.stock_quantity) DESC'), 0, $limit); - - return $db->loadObjectList() ?: []; - } - - /** - * Get parts cost summary for a work order. - */ - public static function getWoPartsCost(int $woId): object - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('COUNT(*) AS item_count') - ->select('SUM(quantity) AS total_qty') - ->select('COALESCE(SUM(line_total), 0) AS total_cost') - ->from('#__mokosuitefield_wo_items') - ->where('wo_id = ' . (int) $woId)); - - return $db->loadObject() ?: (object) ['item_count' => 0, 'total_qty' => 0, 'total_cost' => 0]; - } -} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php deleted file mode 100644 index 701348c..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php +++ /dev/null @@ -1,239 +0,0 @@ -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/SafetyChecklistHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/SafetyChecklistHelper.php deleted file mode 100644 index c789fde..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/SafetyChecklistHelper.php +++ /dev/null @@ -1,147 +0,0 @@ -get(DatabaseInterface::class); - $now = Factory::getDate()->toSql(); - - $items = self::getDefaultItems($trade); - - $checklist = (object) [ - 'wo_id' => $woId, - 'trade' => $trade, - 'status' => 'pending', - 'created' => $now, - 'created_by' => Factory::getApplication()->getIdentity()->id, - ]; - $db->insertObject('#__mokosuitefield_safety_checklists', $checklist, 'id'); - $checklistId = (int) $checklist->id; - - foreach ($items as $i => $item) { - $db->insertObject('#__mokosuitefield_safety_checklist_items', (object) [ - 'checklist_id' => $checklistId, - 'item_text' => $item, - 'checked' => 0, - 'ordering' => $i + 1, - ]); - } - - return $checklistId; - } - - /** - * Complete a checklist item. - */ - public static function checkItem(int $itemId, bool $passed, string $notes = ''): bool - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - // Verify item belongs to a pending checklist and is not already checked - $db->setQuery($db->getQuery(true) - ->select('sci.id, sci.checked, sc.status AS checklist_status') - ->from($db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci')) - ->join('INNER', $db->quoteName('#__mokosuitefield_safety_checklists', 'sc') . ' ON sc.id = sci.checklist_id') - ->where('sci.id = ' . (int) $itemId)); - $existing = $db->loadObject(); - - if (!$existing || $existing->checklist_status !== 'pending' || (int) $existing->checked === 1) { - return false; - } - - $update = (object) [ - 'id' => $itemId, - 'checked' => 1, - 'passed' => $passed ? 1 : 0, - 'notes' => $notes, - 'checked_at' => Factory::getDate()->toSql(), - 'checked_by' => Factory::getApplication()->getIdentity()->id, - ]; - - $db->updateObject('#__mokosuitefield_safety_checklist_items', $update, 'id'); - - // Auto-complete checklist if all items are checked - $db->setQuery($db->getQuery(true) - ->select('sc.id, COUNT(sci2.id) AS total, SUM(CASE WHEN sci2.checked = 1 THEN 1 ELSE 0 END) AS done') - ->from($db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci2')) - ->join('INNER', $db->quoteName('#__mokosuitefield_safety_checklists', 'sc') . ' ON sc.id = sci2.checklist_id') - ->where('sci2.id = ' . (int) $itemId) - ->group('sc.id')); - $progress = $db->loadObject(); - - if ($progress && (int) $progress->done === (int) $progress->total) { - $db->setQuery($db->getQuery(true) - ->update('#__mokosuitefield_safety_checklists') - ->set($db->quoteName('status') . ' = ' . $db->quote('completed')) - ->where('id = ' . (int) $progress->id)); - $db->execute(); - } - - return true; - } - - /** - * Get checklist completion status for a work order. - */ - public static function getStatus(int $woId): ?object - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('sc.id, sc.status') - ->select('COUNT(sci.id) AS total_items') - ->select('SUM(CASE WHEN sci.checked = 1 THEN 1 ELSE 0 END) AS checked_items') - ->select('SUM(CASE WHEN sci.checked = 1 AND sci.passed = 0 THEN 1 ELSE 0 END) AS failed_items') - ->from($db->quoteName('#__mokosuitefield_safety_checklists', 'sc')) - ->join('LEFT', $db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci') . ' ON sci.checklist_id = sc.id') - ->where('sc.wo_id = ' . (int) $woId) - ->group('sc.id') - ->order('sc.created DESC')); - - $status = $db->loadObject(); - if (!$status) return null; - - $status->complete = (int) $status->checked_items === (int) $status->total_items; - $status->all_passed = (int) $status->failed_items === 0; - $status->safe_to_proceed = $status->complete && $status->all_passed; - - return $status; - } - - /** - * Get default safety items by trade. - */ - private static function getDefaultItems(string $trade): array - { - $common = [ - 'PPE worn (gloves, safety glasses, boots)', - 'Work area inspected for hazards', - 'Tools and equipment in good condition', - 'Fire extinguisher accessible', - 'Emergency exits identified', - ]; - - $tradeSpecific = match ($trade) { - 'electrical' => ['Lockout/tagout verified', 'Voltage tester functional', 'Grounding confirmed', 'Arc flash boundaries marked'], - 'plumbing' => ['Water supply shut off', 'Gas lines identified and marked', 'Asbestos check for older buildings', 'Drain protection in place'], - 'hvac' => ['Refrigerant handling certification verified', 'Electrical isolation confirmed', 'Ductwork supports inspected', 'Ladder/scaffold secured'], - 'general' => ['Work permit obtained if required', 'Material Safety Data Sheets reviewed'], - default => [], - }; - - return array_merge($common, $tradeSpecific); - } -} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/SchedulingHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/SchedulingHelper.php deleted file mode 100644 index c62c6d4..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/SchedulingHelper.php +++ /dev/null @@ -1,152 +0,0 @@ -get(DatabaseInterface::class); - $params = Factory::getApplication()->getParams('com_mokosuitefield'); - - $startHour = (int) $params->get('schedule_start_hour', 8); - $endHour = (int) $params->get('schedule_end_hour', 17); - $slotInterval = (int) $params->get('slot_interval_minutes', 30); - - // Get available techs for this trade - $db->setQuery($db->getQuery(true) - ->select('t.id, cd.name AS tech_name, t.max_daily_jobs') - ->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.scheduled_date = ' . $db->quote($date) . ' AND wo.status NOT IN (' . $db->quote('cancelled') . ',' . $db->quote('completed') . ')) AS booked_count') - ->from($db->quoteName('#__mokosuitefield_technicians', 't')) - ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') - ->where($db->quoteName('t.status') . ' = ' . $db->quote('available')) - ->where('(' . $db->quoteName('t.trade') . ' = ' . $db->quote($trade) . ' OR ' . $db->quoteName('t.trade') . ' = ' . $db->quote('multi_trade') . ')') - ->having('booked_count < t.max_daily_jobs')); - $techs = $db->loadObjectList() ?: []; - - if (empty($techs)) return []; - - // Get existing WO times for these techs - $techIds = array_column($techs, 'id'); - $db->setQuery($db->getQuery(true) - ->select('technician_id, scheduled_time, estimated_duration') - ->from('#__mokosuitefield_work_orders') - ->where('scheduled_date = ' . $db->quote($date)) - ->where('technician_id IN (' . implode(',', array_map('intval', $techIds)) . ')') - ->where($db->quoteName('status') . ' NOT IN (' . $db->quote('cancelled') . ',' . $db->quote('completed') . ')')); - $existingWOs = $db->loadObjectList() ?: []; - - // Build blocked time ranges per tech - $blocked = []; - foreach ($existingWOs as $wo) { - $start = strtotime($wo->scheduled_time); - $end = $start + ((int) ($wo->estimated_duration ?? 60)) * 60; - $blocked[$wo->technician_id][] = [$start, $end]; - } - - // Generate slots - $slots = []; - $current = strtotime($date . ' ' . str_pad($startHour, 2, '0', STR_PAD_LEFT) . ':00:00'); - $dayEnd = strtotime($date . ' ' . str_pad($endHour, 2, '0', STR_PAD_LEFT) . ':00:00') - ($durationMin * 60); - - while ($current <= $dayEnd) { - $slotEnd = $current + ($durationMin * 60); - - // Find at least one available tech for this slot - $availableTechs = []; - foreach ($techs as $tech) { - $conflict = false; - foreach ($blocked[$tech->id] ?? [] as [$bStart, $bEnd]) { - if ($current < $bEnd && $slotEnd > $bStart) { - $conflict = true; - break; - } - } - if (!$conflict) { - $availableTechs[] = $tech->tech_name; - } - } - - if (!empty($availableTechs)) { - $slots[] = (object) [ - 'time' => date('H:i', $current), - 'display' => date('g:i A', $current), - 'available_techs' => count($availableTechs), - ]; - } - - $current += $slotInterval * 60; - } - - return $slots; - } - - /** - * Schedule a work order into a specific slot. - */ - public static function scheduleWorkOrder(int $woId, string $date, string $time, ?int $techId = null): bool - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - // Auto-assign tech if not specified - if (!$techId) { - $wo = $db->setQuery($db->getQuery(true)->select('trade')->from('#__mokosuitefield_work_orders')->where('id = ' . (int) $woId))->loadObject(); - $bestTech = DispatchHelper::findBestTech($wo->trade ?? 'general', ''); - $techId = $bestTech ? (int) $bestTech->id : null; - } - - $update = (object) [ - 'id' => $woId, - 'scheduled_date' => $date, - 'scheduled_time' => $time, - 'technician_id' => $techId, - 'status' => 'scheduled', - ]; - - return $db->updateObject('#__mokosuitefield_work_orders', $update, 'id'); - } - - /** - * Get today's schedule for all techs. - */ - public static function getTodaySchedule(): array - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('t.id AS tech_id, cd.name AS tech_name, t.trade') - ->from($db->quoteName('#__mokosuitefield_technicians', 't')) - ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') - ->where($db->quoteName('t.status') . ' != ' . $db->quote('inactive')) - ->order('cd.name ASC')); - $techs = $db->loadObjectList() ?: []; - - foreach ($techs as &$tech) { - $db->setQuery($db->getQuery(true) - ->select('wo.id, wo.wo_number, wo.scheduled_time, wo.estimated_duration, wo.status, wo.priority') - ->select('loc.address, loc.city') - ->select('cd2.name AS customer_name') - ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) - ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') - ->join('LEFT', $db->quoteName('#__contact_details', 'cd2') . ' ON cd2.id = wo.contact_id') - ->where('wo.technician_id = ' . (int) $tech->tech_id) - ->where('wo.scheduled_date = CURDATE()') - ->where($db->quoteName('wo.status') . ' != ' . $db->quote('cancelled')) - ->order('wo.scheduled_time ASC')); - $tech->jobs = $db->loadObjectList() ?: []; - $tech->job_count = count($tech->jobs); - } - - return $techs; - } -} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/ServiceAgreementHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/ServiceAgreementHelper.php deleted file mode 100644 index ab2f919..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/ServiceAgreementHelper.php +++ /dev/null @@ -1,78 +0,0 @@ -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/TechnicianSkillHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/TechnicianSkillHelper.php deleted file mode 100644 index bbc95d1..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/TechnicianSkillHelper.php +++ /dev/null @@ -1,97 +0,0 @@ -get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('t.id AS tech_id, cd.name AS tech_name') - ->select('GROUP_CONCAT(ts.skill_name ORDER BY ts.skill_name SEPARATOR ", ") AS skills') - ->select('COUNT(ts.id) AS skill_count') - ->select('SUM(CASE WHEN ts.certification_expires IS NOT NULL AND ts.certification_expires < NOW() THEN 1 ELSE 0 END) AS expired_certs') - ->from($db->quoteName('#__mokosuitefield_technicians', 't')) - ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') - ->join('LEFT', $db->quoteName('#__mokosuitefield_tech_skills', 'ts') . ' ON ts.tech_id = t.id') - ->where($db->quoteName('t.status') . ' = ' . $db->quote('active')) - ->group('t.id') - ->order('cd.name ASC')); - - return $db->loadObjectList() ?: []; - } - - /** - * Find best technician match for a work order based on required skills. - */ - public static function findBestMatch(array $requiredSkills, string $date = ''): array - { - if (empty($requiredSkills)) { - return []; - } - - $date = $date ?: date('Y-m-d'); - if (!\DateTime::createFromFormat('Y-m-d', $date)) { - throw new \InvalidArgumentException('Date must be Y-m-d format.'); - } - - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $skillPlaceholders = implode(',', array_map(fn($s) => $db->quote($s), $requiredSkills)); - - $db->setQuery($db->getQuery(true) - ->select('t.id AS tech_id, cd.name AS tech_name, t.hourly_rate') - ->select('COUNT(DISTINCT ts.skill_name) AS matching_skills') - ->select((string) count($requiredSkills) . ' AS required_skills') - ->from($db->quoteName('#__mokosuitefield_technicians', 't')) - ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') - ->join('INNER', $db->quoteName('#__mokosuitefield_tech_skills', 'ts') - . ' ON ts.tech_id = t.id AND ts.skill_name IN (' . $skillPlaceholders . ')' - . ' AND (ts.certification_expires IS NULL OR ts.certification_expires > ' . $db->quote($date) . ')') - ->where($db->quoteName('t.status') . ' = ' . $db->quote('active')) - ->group('t.id') - ->order('matching_skills DESC, t.hourly_rate ASC')); - - $matches = $db->loadObjectList() ?: []; - - foreach ($matches as &$m) { - $m->match_pct = round((int) $m->matching_skills / (int) $m->required_skills * 100, 1); - } - - return $matches; - } - - /** - * Get expiring certifications within N days. - */ - public static function getExpiringCertifications(int $days = 30): array - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - $cutoff = date('Y-m-d', strtotime("+{$days} days")); - - $db->setQuery($db->getQuery(true) - ->select('ts.id, ts.skill_name, ts.certification_number, ts.certification_expires') - ->select('cd.name AS tech_name, cd.email_to') - ->from($db->quoteName('#__mokosuitefield_tech_skills', 'ts')) - ->join('INNER', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = ts.tech_id') - ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') - ->where($db->quoteName('t.status') . ' = ' . $db->quote('active')) - ->where('ts.certification_expires IS NOT NULL') - ->where('ts.certification_expires BETWEEN NOW() AND ' . $db->quote($cutoff)) - ->order('ts.certification_expires ASC')); - - return $db->loadObjectList() ?: []; - } -} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/TruckStockHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/TruckStockHelper.php deleted file mode 100644 index 7ed3a91..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/TruckStockHelper.php +++ /dev/null @@ -1,69 +0,0 @@ -get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('ts.*, p.name AS part_name, p.sku, p.cost_price') - ->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts')) - ->join('INNER', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id') - ->where('ts.vehicle_id = ' . $vehicleId) - ->order('p.name ASC')); - - return $db->loadObjectList() ?: []; - } - - public static function getLowStock(): array - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('ts.*, p.name AS part_name, p.sku, v.vehicle_number') - ->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts')) - ->join('INNER', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id') - ->join('INNER', $db->quoteName('#__mokosuitefield_vehicles', 'v') . ' ON v.id = ts.vehicle_id') - ->where('ts.quantity <= ts.min_quantity') - ->order('ts.quantity ASC')); - - return $db->loadObjectList() ?: []; - } - - public static function usePart(int $vehicleId, int $productId, float $qty = 1): bool - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->update('#__mokosuitefield_truck_stock') - ->set('quantity = quantity - ' . (float) $qty) - ->where('vehicle_id = ' . (int) $vehicleId) - ->where('product_id = ' . (int) $productId) - ->where('quantity >= ' . (float) $qty)); - $db->execute(); - - return $db->getAffectedRows() > 0; - } - - public static function restock(int $vehicleId, int $productId, float $qty): void - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $db->setQuery( - 'INSERT INTO #__mokosuitefield_truck_stock (vehicle_id, product_id, quantity, last_restocked)' - . ' VALUES (' . (int) $vehicleId . ', ' . (int) $productId . ', ' . (float) $qty . ', CURDATE())' - . ' ON DUPLICATE KEY UPDATE quantity = quantity + ' . (float) $qty . ', last_restocked = CURDATE()' - ); - $db->execute(); - } -} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/VehicleHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/VehicleHelper.php deleted file mode 100644 index 0d32603..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/VehicleHelper.php +++ /dev/null @@ -1,44 +0,0 @@ -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/WarrantyHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/WarrantyHelper.php deleted file mode 100644 index ae56d77..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/WarrantyHelper.php +++ /dev/null @@ -1,102 +0,0 @@ -get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('e.id, e.serial_number, e.model, e.install_date') - ->select('e.warranty_start, e.warranty_end, e.warranty_provider, e.warranty_type') - ->from($db->quoteName('#__mokosuitefield_equipment', 'e')) - ->where('e.id = ' . (int) $equipmentId)); - $equipment = $db->loadObject(); - - if (!$equipment) return (object) ['found' => false]; - - $now = new \DateTime('today'); - $warrantyEnd = $equipment->warranty_end ? new \DateTime($equipment->warranty_end) : null; - - $equipment->under_warranty = $warrantyEnd && $now <= $warrantyEnd; - $equipment->days_remaining = $warrantyEnd ? max(0, (int) $now->diff($warrantyEnd)->format('%r%a')) : null; - $equipment->warranty_expired = $warrantyEnd && $now > $warrantyEnd; - - // Get claim history - $db->setQuery($db->getQuery(true) - ->select('COUNT(*) AS total_claims, COALESCE(SUM(claim_amount), 0) AS total_claimed') - ->from('#__mokosuitefield_warranty_claims') - ->where('equipment_id = ' . (int) $equipmentId)); - $claims = $db->loadObject(); - - $equipment->total_claims = (int) ($claims->total_claims ?? 0); - $equipment->total_claimed = (float) ($claims->total_claimed ?? 0); - - return $equipment; - } - - /** - * Submit a warranty claim. - */ - public static function submitClaim(int $equipmentId, int $woId, string $description, float $claimAmount): object - { - $warranty = self::checkWarranty($equipmentId); - - if (empty($warranty->id)) return (object) ['success' => false, 'error' => 'Equipment not found']; - if (!$warranty->under_warranty) return (object) ['success' => false, 'error' => 'Warranty expired']; - - $db = Factory::getContainer()->get(DatabaseInterface::class); - - // Verify work order exists and is linked to this equipment - $db->setQuery($db->getQuery(true)->select('id')->from('#__mokosuitefield_work_orders') - ->where('id = ' . (int) $woId)); - if (!(int) $db->loadResult()) { - return (object) ['success' => false, 'error' => 'Invalid work order']; - } - $now = Factory::getDate()->toSql(); - - $claim = (object) [ - 'equipment_id' => $equipmentId, - 'wo_id' => $woId, - 'description' => $description, - 'claim_amount' => $claimAmount, - 'status' => 'submitted', - 'submitted_at' => $now, - 'submitted_by' => Factory::getApplication()->getIdentity()->id, - ]; - - $db->insertObject('#__mokosuitefield_warranty_claims', $claim, 'id'); - return (object) ['success' => true, 'claim_id' => (int) $claim->id]; - } - - /** - * Get equipment with warranties expiring within N days. - */ - public static function getExpiringSoon(int $days = 90): array - { - $db = Factory::getContainer()->get(DatabaseInterface::class); - - $db->setQuery($db->getQuery(true) - ->select('e.*, l.name AS location_name, cd.name AS customer_name') - ->from($db->quoteName('#__mokosuitefield_equipment', 'e')) - ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'l') . ' ON l.id = e.location_id') - ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') - ->where($db->quoteName('e.warranty_end') . ' IS NOT NULL') - ->where($db->quoteName('e.warranty_end') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . (int) $days . ' DAY)') - ->order('e.warranty_end ASC')); - - return $db->loadObjectList() ?: []; - } -} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php deleted file mode 100644 index e6270e2..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php +++ /dev/null @@ -1,162 +0,0 @@ -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 deleted file mode 100644 index 9fbdc96..0000000 --- a/source/packages/plg_task_mokosuitefield/src/Extension/FieldAutomation.php +++ /dev/null @@ -1,144 +0,0 @@ - [ - '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 deleted file mode 100644 index 4dfb1fa..0000000 --- a/source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteFieldApi.php +++ /dev/null @@ -1,27 +0,0 @@ - '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 index 7ab1413..eef4e51 100644 --- a/source/pkg_mokosuitefield.xml +++ b/source/pkg_mokosuitefield.xml @@ -1,24 +1,28 @@ + - Package - MokoSuite Field - mokosuitefield - 01.08.11 - 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 - + pkg_mokosuitefield + mokosuitefield + 0.1.0 + 2026-06-27 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting + GPL-3.0-or-later + PKG_MOKOSUITEFIELD_DESCRIPTION + + + components/com_mokosuitefield + plugins/system/mokosuitefield + plugins/webservices/mokosuitefield + + + + https://git.mokoconsulting.tech/api/packages/MokoConsulting/generic/pkg_mokosuitefield/latest/updates.xml + diff --git a/source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini b/source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini new file mode 100644 index 0000000..bdb135a --- /dev/null +++ b/source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini @@ -0,0 +1,13 @@ +; Copyright (C) 2026 Moko Consulting +; SPDX-License-Identifier: GPL-3.0-or-later +; Authored-by: Moko Consulting + +PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field" +PLG_SYSTEM_MOKOSUITEFIELD_DESCRIPTION="Field service management system plugin for MokoSuite." +PLG_SYSTEM_MOKOSUITEFIELD_PARAM_COMPANY_NAME="Company Name" +PLG_SYSTEM_MOKOSUITEFIELD_PARAM_SERVICE_RADIUS="Service Radius (km)" +PLG_SYSTEM_MOKOSUITEFIELD_PARAM_AUTO_DISPATCH="Auto-dispatch" +PLG_SYSTEM_MOKOSUITEFIELD_PARAM_TIMEOUT_MINUTES="Dispatch Timeout (minutes)" +PLG_SYSTEM_MOKOSUITEFIELD_PARAM_DEFAULT_HOURLY_RATE="Default Hourly Rate" +PLG_SYSTEM_MOKOSUITEFIELD_PARAM_TAX_RATE="Tax Rate (%)" +PLG_SYSTEM_MOKOSUITEFIELD_PARAM_LOW_STOCK_THRESHOLD="Low Stock Threshold" diff --git a/source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini b/source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini new file mode 100644 index 0000000..fcc87ca --- /dev/null +++ b/source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini @@ -0,0 +1,6 @@ +; Copyright (C) 2026 Moko Consulting +; SPDX-License-Identifier: GPL-3.0-or-later +; Authored-by: Moko Consulting + +PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field" +PLG_SYSTEM_MOKOSUITEFIELD_DESCRIPTION="Field service management system plugin for MokoSuite." diff --git a/source/plugins/system/mokosuitefield/mokosuitefield.xml b/source/plugins/system/mokosuitefield/mokosuitefield.xml new file mode 100644 index 0000000..b056150 --- /dev/null +++ b/source/plugins/system/mokosuitefield/mokosuitefield.xml @@ -0,0 +1,106 @@ + + + + plg_system_mokosuitefield + 0.1.0 + 2026-06-27 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting + GPL-3.0-or-later + PLG_SYSTEM_MOKOSUITEFIELD_DESCRIPTION + + Moko\Plugin\System\MokoSuiteField + + + src + services + sql + language + + + + sql/install.sql + + + sql/uninstall.sql + + + + en-GB/plg_system_mokosuitefield.ini + en-GB/plg_system_mokosuitefield.sys.ini + + + + +
+ + +
+
+ + + + + +
+
+ + +
+
+ +
+
+
+
diff --git a/source/plugins/system/mokosuitefield/services/provider.php b/source/plugins/system/mokosuitefield/services/provider.php new file mode 100644 index 0000000..8f148f1 --- /dev/null +++ b/source/plugins/system/mokosuitefield/services/provider.php @@ -0,0 +1,36 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +\defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Moko\Plugin\System\MokoSuiteField\Extension\MokoSuiteField; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $dispatcher = $container->get(DispatcherInterface::class); + $plugin = new MokoSuiteField( + $dispatcher, + (array) PluginHelper::getPlugin('system', 'mokosuitefield') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/source/plugins/system/mokosuitefield/sql/install.sql b/source/plugins/system/mokosuitefield/sql/install.sql new file mode 100644 index 0000000..199f5f4 --- /dev/null +++ b/source/plugins/system/mokosuitefield/sql/install.sql @@ -0,0 +1,170 @@ +-- Copyright (C) 2026 Moko Consulting +-- SPDX-License-Identifier: GPL-3.0-or-later +-- Authored-by: Moko Consulting + +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_technicians` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `contact_id` INT DEFAULT NULL, + `name` VARCHAR(255) NOT NULL, + `email` VARCHAR(255) NOT NULL DEFAULT '', + `phone` VARCHAR(50) NOT NULL DEFAULT '', + `skills` VARCHAR(500) NOT NULL DEFAULT '', + `hourly_rate` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `status` ENUM('active','inactive','on_leave','training') NOT NULL DEFAULT 'active', + `current_lat` DECIMAL(10,7) DEFAULT NULL, + `current_lng` DECIMAL(10,7) DEFAULT NULL, + `published` TINYINT NOT NULL DEFAULT 1, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_contact` (`contact_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_equipment` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `customer_contact_id` INT DEFAULT NULL, + `name` VARCHAR(255) NOT NULL, + `equipment_type` ENUM('hvac','plumbing','electrical','appliance','generator','elevator','fire_system','other') NOT NULL DEFAULT 'other', + `brand` VARCHAR(100) NOT NULL DEFAULT '', + `model` VARCHAR(100) NOT NULL DEFAULT '', + `serial_number` VARCHAR(100) NOT NULL DEFAULT '', + `install_date` DATE DEFAULT NULL, + `warranty_expiry` DATE DEFAULT NULL, + `location_address` VARCHAR(500) NOT NULL DEFAULT '', + `notes` TEXT, + `published` TINYINT NOT NULL DEFAULT 1, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_customer` (`customer_contact_id`), + KEY `idx_type` (`equipment_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_equipment_history` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `equipment_id` INT UNSIGNED NOT NULL, + `work_order_id` INT UNSIGNED DEFAULT NULL, + `action` ENUM('install','repair','maintenance','inspection','replacement','decommission') NOT NULL DEFAULT 'maintenance', + `description` VARCHAR(500) NOT NULL DEFAULT '', + `technician_id` INT UNSIGNED DEFAULT NULL, + `action_date` DATE NOT NULL, + `cost` DECIMAL(10,2) DEFAULT NULL, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_equipment` (`equipment_id`), + KEY `idx_date` (`action_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_work_orders` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `work_order_ref` VARCHAR(20) NOT NULL, + `customer_contact_id` INT DEFAULT NULL, + `customer_name` VARCHAR(255) NOT NULL, + `customer_phone` VARCHAR(50) NOT NULL DEFAULT '', + `equipment_id` INT UNSIGNED DEFAULT NULL, + `technician_id` INT UNSIGNED DEFAULT NULL, + `status` ENUM('requested','scheduled','dispatched','in_progress','on_hold','completed','invoiced','cancelled') NOT NULL DEFAULT 'requested', + `priority` ENUM('emergency','high','normal','low') NOT NULL DEFAULT 'normal', + `work_type` ENUM('repair','maintenance','installation','inspection','warranty','callback') NOT NULL DEFAULT 'repair', + `title` VARCHAR(255) NOT NULL, + `description` TEXT, + `site_address` VARCHAR(500) NOT NULL, + `site_lat` DECIMAL(10,7) DEFAULT NULL, + `site_lng` DECIMAL(10,7) DEFAULT NULL, + `scheduled_date` DATE DEFAULT NULL, + `dispatched_at` DATETIME DEFAULT NULL, + `completed_at` DATETIME DEFAULT NULL, + `labor_hours` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `labor_cost` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `parts_cost` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `total_cost` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `customer_signature` TEXT, + `notes` TEXT, + `created` DATETIME NOT NULL, + `created_by` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_ref` (`work_order_ref`), + KEY `idx_customer` (`customer_contact_id`), + KEY `idx_technician` (`technician_id`), + KEY `idx_status` (`status`), + KEY `idx_scheduled` (`scheduled_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_parts` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `part_number` VARCHAR(100) NOT NULL DEFAULT '', + `category` VARCHAR(100) NOT NULL DEFAULT '', + `unit_cost` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `sell_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `stock_qty` INT NOT NULL DEFAULT 0, + `reorder_level` INT UNSIGNED NOT NULL DEFAULT 5, + `supplier` VARCHAR(255) NOT NULL DEFAULT '', + `published` TINYINT NOT NULL DEFAULT 1, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_part_number` (`part_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_truck_inventory` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `technician_id` INT UNSIGNED NOT NULL, + `part_id` INT UNSIGNED NOT NULL, + `quantity` INT NOT NULL DEFAULT 0, + `last_restocked` DATE DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_tech_part` (`technician_id`, `part_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_checklists` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `work_type` ENUM('repair','maintenance','installation','inspection','warranty','callback','all') NOT NULL DEFAULT 'all', + `published` TINYINT NOT NULL DEFAULT 1, + `ordering` INT NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_checklist_items` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `checklist_id` INT UNSIGNED NOT NULL, + `label` VARCHAR(255) NOT NULL, + `item_type` ENUM('checkbox','text','number','photo','pass_fail') NOT NULL DEFAULT 'checkbox', + `required` TINYINT NOT NULL DEFAULT 0, + `ordering` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_checklist` (`checklist_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_pm_agreements` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `customer_contact_id` INT NOT NULL, + `equipment_id` INT UNSIGNED DEFAULT NULL, + `name` VARCHAR(255) NOT NULL, + `frequency` ENUM('monthly','quarterly','semi_annual','annual') NOT NULL DEFAULT 'annual', + `next_service_date` DATE DEFAULT NULL, + `annual_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `status` ENUM('active','expired','cancelled') NOT NULL DEFAULT 'active', + `start_date` DATE NOT NULL, + `end_date` DATE DEFAULT NULL, + `auto_renew` TINYINT NOT NULL DEFAULT 1, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_customer` (`customer_contact_id`), + KEY `idx_next_service` (`next_service_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_dispatches` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `work_order_id` INT UNSIGNED NOT NULL, + `technician_id` INT UNSIGNED NOT NULL, + `status` ENUM('offered','accepted','rejected','expired','cancelled') NOT NULL DEFAULT 'offered', + `offered_at` DATETIME NOT NULL, + `responded_at` DATETIME DEFAULT NULL, + `distance_km` DECIMAL(10,2) DEFAULT NULL, + `eta_minutes` DECIMAL(10,2) DEFAULT NULL, + `attempt_number` TINYINT UNSIGNED NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + KEY `idx_work_order` (`work_order_id`), + KEY `idx_technician` (`technician_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/source/plugins/system/mokosuitefield/sql/uninstall.sql b/source/plugins/system/mokosuitefield/sql/uninstall.sql new file mode 100644 index 0000000..d919198 --- /dev/null +++ b/source/plugins/system/mokosuitefield/sql/uninstall.sql @@ -0,0 +1,14 @@ +-- Copyright (C) 2026 Moko Consulting +-- SPDX-License-Identifier: GPL-3.0-or-later +-- Authored-by: Moko Consulting + +DROP TABLE IF EXISTS `#__mokosuitefield_dispatches`; +DROP TABLE IF EXISTS `#__mokosuitefield_pm_agreements`; +DROP TABLE IF EXISTS `#__mokosuitefield_checklist_items`; +DROP TABLE IF EXISTS `#__mokosuitefield_checklists`; +DROP TABLE IF EXISTS `#__mokosuitefield_truck_inventory`; +DROP TABLE IF EXISTS `#__mokosuitefield_parts`; +DROP TABLE IF EXISTS `#__mokosuitefield_work_orders`; +DROP TABLE IF EXISTS `#__mokosuitefield_equipment_history`; +DROP TABLE IF EXISTS `#__mokosuitefield_equipment`; +DROP TABLE IF EXISTS `#__mokosuitefield_technicians`; diff --git a/source/plugins/system/mokosuitefield/src/Extension/MokoSuiteField.php b/source/plugins/system/mokosuitefield/src/Extension/MokoSuiteField.php new file mode 100644 index 0000000..b7627a0 --- /dev/null +++ b/source/plugins/system/mokosuitefield/src/Extension/MokoSuiteField.php @@ -0,0 +1,23 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +namespace Moko\Plugin\System\MokoSuiteField\Extension; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Event\DispatcherInterface; +use Joomla\Event\SubscriberInterface; + +\defined('_JEXEC') or die; + +final class MokoSuiteField extends CMSPlugin implements SubscriberInterface +{ + public static function getSubscribedEvents(): array + { + return []; + } +} diff --git a/source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.ini b/source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.ini new file mode 100644 index 0000000..38d8d0d --- /dev/null +++ b/source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.ini @@ -0,0 +1,6 @@ +; Copyright (C) 2026 Moko Consulting +; SPDX-License-Identifier: GPL-3.0-or-later +; Authored-by: Moko Consulting + +PLG_WEBSERVICES_MOKOSUITEFIELD="Web Services - MokoSuite Field" +PLG_WEBSERVICES_MOKOSUITEFIELD_DESCRIPTION="Provides API routes for MokoSuite Field service management." diff --git a/source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.sys.ini b/source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.sys.ini new file mode 100644 index 0000000..38d8d0d --- /dev/null +++ b/source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.sys.ini @@ -0,0 +1,6 @@ +; Copyright (C) 2026 Moko Consulting +; SPDX-License-Identifier: GPL-3.0-or-later +; Authored-by: Moko Consulting + +PLG_WEBSERVICES_MOKOSUITEFIELD="Web Services - MokoSuite Field" +PLG_WEBSERVICES_MOKOSUITEFIELD_DESCRIPTION="Provides API routes for MokoSuite Field service management." diff --git a/source/plugins/webservices/mokosuitefield/mokosuitefield.xml b/source/plugins/webservices/mokosuitefield/mokosuitefield.xml new file mode 100644 index 0000000..63c2610 --- /dev/null +++ b/source/plugins/webservices/mokosuitefield/mokosuitefield.xml @@ -0,0 +1,30 @@ + + + + plg_webservices_mokosuitefield + 0.1.0 + 2026-06-27 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting + GPL-3.0-or-later + PLG_WEBSERVICES_MOKOSUITEFIELD_DESCRIPTION + + Moko\Plugin\WebServices\MokoSuiteField + + + src + services + language + + + + en-GB/plg_webservices_mokosuitefield.ini + en-GB/plg_webservices_mokosuitefield.sys.ini + + diff --git a/source/plugins/webservices/mokosuitefield/services/provider.php b/source/plugins/webservices/mokosuitefield/services/provider.php new file mode 100644 index 0000000..f46086f --- /dev/null +++ b/source/plugins/webservices/mokosuitefield/services/provider.php @@ -0,0 +1,36 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +\defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Moko\Plugin\WebServices\MokoSuiteField\Extension\MokoSuiteField; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $dispatcher = $container->get(DispatcherInterface::class); + $plugin = new MokoSuiteField( + $dispatcher, + (array) PluginHelper::getPlugin('webservices', 'mokosuitefield') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/source/plugins/webservices/mokosuitefield/src/Extension/MokoSuiteField.php b/source/plugins/webservices/mokosuitefield/src/Extension/MokoSuiteField.php new file mode 100644 index 0000000..ce2562f --- /dev/null +++ b/source/plugins/webservices/mokosuitefield/src/Extension/MokoSuiteField.php @@ -0,0 +1,50 @@ + + * @license GPL-3.0-or-later + * @author Moko Consulting + */ + +namespace Moko\Plugin\WebServices\MokoSuiteField\Extension; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Router\ApiRouter; +use Joomla\Event\SubscriberInterface; +use Joomla\Router\Route; + +\defined('_JEXEC') or die; + +final class MokoSuiteField extends CMSPlugin implements SubscriberInterface +{ + public static function getSubscribedEvents(): array + { + return [ + 'onBeforeApiRoute' => 'onBeforeApiRoute', + ]; + } + + public function onBeforeApiRoute(&$router): void + { + $routes = [ + 'workorders' => 'fieldworkorders', + 'technicians' => 'fieldtechnicians', + 'equipment' => 'fieldequipment', + 'parts' => 'fieldparts', + 'checklists' => 'fieldchecklists', + 'agreements' => 'fieldagreements', + 'dispatches' => 'fielddispatches', + ]; + + $component = 'com_mokosuitefield'; + $defaults = ['component' => $component, 'public' => false]; + + foreach ($routes as $endpoint => $view) { + $router->createCRUDRoutes( + "v1/mokosuitefield/{$endpoint}", + $view, + $defaults + ); + } + } +}