diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..10f5bf5 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,33 @@ +name: Build Package +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Build package ZIP + run: | + cd source + # Create individual package ZIPs + for pkg_dir in packages/*/; do + pkg_name=$(basename "$pkg_dir") + cd "$pkg_dir" + zip -r "../../${pkg_name}.zip" . -x "*.git*" + cd ../.. + done + # Create main package ZIP with all sub-packages + manifest + zip -j "pkg_mokosuitefield.zip" pkg_*.xml script.php updates.xml *.zip 2>/dev/null || true + ls -la *.zip + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: source/pkg_mokosuitefield.zip + generate_release_notes: true diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1321885 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "packages/MokoSuiteClient"] + path = packages/MokoSuiteClient + url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient.git +[submodule "packages/MokoSuiteCRM"] + path = packages/MokoSuiteCRM + url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM.git diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d001f04 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## [01.01.00] - 2026-06-12 +### Added +- Initial scaffold: field service management for MokoSuite +- 12 database tables for technicians, work orders, service agreements, equipment, vehicles, estimates +- 7 helpers: Dispatch, WorkOrder, ServiceAgreement, Equipment, Estimate, TruckStock, Vehicle +- Admin views: Dashboard, Work Orders, Technicians, Service Agreements, Equipment, Dispatch, Vehicles +- Site views: Tech Mobile (tablet), Book Service (public form) +- API controller with 6 endpoints +- Task scheduler: service reminders, agreement renewals, equipment warranty, truck stock reorder +- Joomla 6 architecture (PHP 8.3+) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..37df8bd --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +GPL-3.0-or-later diff --git a/packages/MokoSuiteCRM b/packages/MokoSuiteCRM new file mode 160000 index 0000000..6c78af1 --- /dev/null +++ b/packages/MokoSuiteCRM @@ -0,0 +1 @@ +Subproject commit 6c78af16e6cd0f31f953f90701c403d60d8db766 diff --git a/packages/MokoSuiteClient b/packages/MokoSuiteClient new file mode 160000 index 0000000..6cd16d9 --- /dev/null +++ b/packages/MokoSuiteClient @@ -0,0 +1 @@ +Subproject commit 6cd16d984589fabbfd0b074b0d3308b5e64967f0 diff --git a/source/packages/com_mokosuitefield/admin/access.xml b/source/packages/com_mokosuitefield/admin/access.xml new file mode 100644 index 0000000..6f4f869 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/access.xml @@ -0,0 +1,11 @@ + + +
+ + + + + + +
+
diff --git a/source/packages/com_mokosuitefield/admin/config.xml b/source/packages/com_mokosuitefield/admin/config.xml new file mode 100644 index 0000000..b17bb28 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/config.xml @@ -0,0 +1,25 @@ + + +
+ + + + + + + + +
+
+ + +
+
+ + + +
+
+ +
+
diff --git a/source/packages/com_mokosuitefield/admin/services/provider.php b/source/packages/com_mokosuitefield/admin/services/provider.php new file mode 100644 index 0000000..f056f8e --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/services/provider.php @@ -0,0 +1,18 @@ +set(ComponentInterface::class, function (Container $container) { + $component = new MVCComponent($container->get(ComponentDispatcherFactoryInterface::class)); + $component->setMVCFactory($container->get(MVCFactoryInterface::class)); + return $component; + }); + } +}; diff --git a/source/packages/com_mokosuitefield/admin/src/Controller/DisplayController.php b/source/packages/com_mokosuitefield/admin/src/Controller/DisplayController.php new file mode 100644 index 0000000..0129bef --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/Controller/DisplayController.php @@ -0,0 +1,11 @@ +getDatabase(); + $db->setQuery($db->getQuery(true) + ->select('t.id AS tech_id, cd.name AS tech_name, t.trade, t.status AS tech_status') + ->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.scheduled_date = CURDATE() AND wo.status NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ')) AS pending_jobs') + ->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.scheduled_date = CURDATE() AND wo.status = ' . $db->quote('completed') . ') AS completed_jobs') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->where($db->quoteName('t.status') . ' != ' . $db->quote('inactive')) + ->order('cd.name ASC')); + return $db->loadObjectList() ?: []; + } + + public function getGpsLog(int $techId, string $date = ''): array + { + $db = $this->getDatabase(); + $date = $date ?: date('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitefield_dispatch_log') + ->where('technician_id = ' . $techId) + ->where('DATE(recorded_at) = ' . $db->quote($date)) + ->order('recorded_at ASC')); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/Model/EquipmentModel.php b/source/packages/com_mokosuitefield/admin/src/Model/EquipmentModel.php new file mode 100644 index 0000000..2f244ad --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/Model/EquipmentModel.php @@ -0,0 +1,29 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('eq.*, loc.name AS location_name, loc.address, cd.name AS customer_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'eq')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = eq.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = eq.contact_id') + ->order('eq.name ASC'); + + if ($type) $query->where($db->quoteName('eq.type') . ' = ' . $db->quote($type)); + if ($status) $query->where($db->quoteName('eq.status') . ' = ' . $db->quote($status)); + if ($locationId) $query->where('eq.location_id = ' . $locationId); + if ($search) $query->where('(' . $db->quoteName('eq.name') . ' LIKE ' . $db->quote('%' . $search . '%') + . ' OR ' . $db->quoteName('eq.serial_number') . ' LIKE ' . $db->quote('%' . $search . '%') . ')'); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/Model/EstimatesModel.php b/source/packages/com_mokosuitefield/admin/src/Model/EstimatesModel.php new file mode 100644 index 0000000..7332a46 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/Model/EstimatesModel.php @@ -0,0 +1,28 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('e.*, cd.name AS customer_name, loc.address') + ->from($db->quoteName('#__mokosuitefield_estimates', 'e')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id') + ->order('e.created DESC'); + + if ($status) $query->where($db->quoteName('e.status') . ' = ' . $db->quote($status)); + if ($techId) $query->where('e.technician_id = ' . $techId); + if ($search) $query->where('(' . $db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%') + . ' OR ' . $db->quoteName('e.estimate_number') . ' LIKE ' . $db->quote('%' . $search . '%') . ')'); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/Model/ServiceAgreementsModel.php b/source/packages/com_mokosuitefield/admin/src/Model/ServiceAgreementsModel.php new file mode 100644 index 0000000..c01a746 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/Model/ServiceAgreementsModel.php @@ -0,0 +1,39 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('sa.*, cd.name AS customer_name, loc.address') + ->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = sa.location_id') + ->order('sa.end_date ASC'); + + if ($status) $query->where($db->quoteName('sa.status') . ' = ' . $db->quote($status)); + if ($search) $query->where($db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%')); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } + + public function getExpiringSoon(int $days = 30): array + { + $db = $this->getDatabase(); + $db->setQuery($db->getQuery(true) + ->select('sa.*, cd.name AS customer_name') + ->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id') + ->where($db->quoteName('sa.status') . ' = ' . $db->quote('active')) + ->where($db->quoteName('sa.end_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $days . ' DAY)') + ->order('sa.end_date ASC')); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/Model/TechniciansModel.php b/source/packages/com_mokosuitefield/admin/src/Model/TechniciansModel.php new file mode 100644 index 0000000..6a32578 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/Model/TechniciansModel.php @@ -0,0 +1,27 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('t.*, cd.name AS tech_name, cd.email_to, cd.telephone') + ->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.status IN (' . $db->quote('dispatched') . ',' . $db->quote('in_progress') . ')) AS active_jobs') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->order('cd.name ASC'); + + if ($status) $query->where($db->quoteName('t.status') . ' = ' . $db->quote($status)); + if ($trade) $query->where($db->quoteName('t.trade') . ' = ' . $db->quote($trade)); + if ($search) $query->where($db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%')); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/Model/VehiclesModel.php b/source/packages/com_mokosuitefield/admin/src/Model/VehiclesModel.php new file mode 100644 index 0000000..0429c3a --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/Model/VehiclesModel.php @@ -0,0 +1,26 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('v.*, cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_vehicles', 'v')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.technician_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->order('v.vehicle_name ASC'); + + if ($status) $query->where($db->quoteName('v.status') . ' = ' . $db->quote($status)); + if ($techId) $query->where('v.technician_id = ' . $techId); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/Model/WorkOrdersModel.php b/source/packages/com_mokosuitefield/admin/src/Model/WorkOrdersModel.php new file mode 100644 index 0000000..af03434 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/Model/WorkOrdersModel.php @@ -0,0 +1,62 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, loc.address, loc.city, loc.state, loc.zip') + ->select('t_cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id') + ->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id') + ->order('wo.scheduled_date DESC, wo.priority DESC'); + + if ($status) $query->where($db->quoteName('wo.status') . ' = ' . $db->quote($status)); + if ($trade) $query->where($db->quoteName('wo.trade') . ' = ' . $db->quote($trade)); + if ($techId) $query->where('wo.technician_id = ' . $techId); + if ($date) $query->where('wo.scheduled_date = ' . $db->quote($date)); + if ($search) { + $query->where('(' . $db->quoteName('wo.wo_number') . ' LIKE ' . $db->quote('%' . $search . '%') + . ' OR ' . $db->quoteName('wo.description') . ' LIKE ' . $db->quote('%' . $search . '%') + . ' OR ' . $db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%') . ')'); + } + + $db->setQuery($query, $offset, $limit); + return $db->loadObjectList() ?: []; + } + + public function getWorkOrder(int $id): ?object + { + $db = $this->getDatabase(); + $db->setQuery($db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, t_cd.name AS tech_name') + ->select('loc.address, loc.city, loc.state, loc.zip, loc.latitude, loc.longitude, loc.name AS location_name') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id') + ->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id') + ->where('wo.id = ' . (int) $id)); + return $db->loadObject(); + } + + public function getStatusCounts(): object + { + $db = $this->getDatabase(); + $db->setQuery($db->getQuery(true) + ->select('status, COUNT(*) AS cnt') + ->from('#__mokosuitefield_work_orders') + ->group('status')); + $rows = $db->loadObjectList('status') ?: []; + return (object) array_map(fn($r) => (int) $r->cnt, (array) $rows); + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/View/Dashboard/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Dashboard/HtmlView.php new file mode 100644 index 0000000..c6b9eaf --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/View/Dashboard/HtmlView.php @@ -0,0 +1,40 @@ +stats = \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::getDashboardStats(); + $this->dispatchBoard = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getDispatchBoard(); + $this->unassigned = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getUnassigned(); + + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Urgent/emergency jobs + $db->setQuery($db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, loc.address') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->where($db->quoteName('wo.priority') . ' IN (' . $db->quote('emergency') . ',' . $db->quote('urgent') . ')') + ->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ',' . $db->quote('invoiced') . ')') + ->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ') ASC, wo.created ASC'), 0, 10); + $this->urgent = $db->loadObjectList() ?: []; + + ToolbarHelper::title('MokoSuite Field Service', 'icon-wrench'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/View/Dispatch/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Dispatch/HtmlView.php new file mode 100644 index 0000000..e59bd25 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/View/Dispatch/HtmlView.php @@ -0,0 +1,28 @@ +date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d')); + + $this->board = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getDispatchBoard($this->date); + $this->unassigned = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getUnassigned(); + $this->stats = \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::getDashboardStats(); + + ToolbarHelper::title('Field Service - Dispatch Board', 'icon-map'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/View/Equipment/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Equipment/HtmlView.php new file mode 100644 index 0000000..5188748 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/View/Equipment/HtmlView.php @@ -0,0 +1,34 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('e.*, loc.address, loc.city, cd.name AS owner_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'e')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->order('e.equipment_type ASC, e.make ASC')); + $this->equipment = $db->loadObjectList() ?: []; + + $this->serviceDue = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getDueForService(30); + + ToolbarHelper::title('Field Service — Equipment', 'icon-cogs'); + ToolbarHelper::addNew('equipment.add'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/View/ServiceAgreements/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/ServiceAgreements/HtmlView.php new file mode 100644 index 0000000..ecec27f --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/View/ServiceAgreements/HtmlView.php @@ -0,0 +1,23 @@ +agreements = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getActiveAgreements(); + $this->revenue = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getRevenueSummary(); + + ToolbarHelper::title('Field Service — Service Agreements', 'icon-file-contract'); + ToolbarHelper::addNew('serviceagreements.add'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/View/Technicians/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Technicians/HtmlView.php new file mode 100644 index 0000000..9f6f903 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/View/Technicians/HtmlView.php @@ -0,0 +1,32 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('t.*, cd.name AS tech_name, cd.telephone, cd.email_to, v.vehicle_number') + ->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.status = ' . $db->quote('completed') . ' AND MONTH(wo.actual_departure) = MONTH(NOW())) AS jobs_this_month') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_vehicles', 'v') . ' ON v.id = t.vehicle_id') + ->order('cd.name ASC')); + $this->technicians = $db->loadObjectList() ?: []; + + ToolbarHelper::title('Field Service — Technicians', 'icon-users'); + ToolbarHelper::addNew('technicians.add'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/View/Vehicles/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Vehicles/HtmlView.php new file mode 100644 index 0000000..1ead5bc --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/View/Vehicles/HtmlView.php @@ -0,0 +1,23 @@ +vehicles = \Moko\Plugin\System\MokoSuiteField\Helper\VehicleHelper::getFleet(); + $this->inspectionsDue = \Moko\Plugin\System\MokoSuiteField\Helper\VehicleHelper::getInspectionsDue(30); + + ToolbarHelper::title('Field Service — Vehicles', 'icon-truck'); + ToolbarHelper::addNew('vehicles.add'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/View/WorkOrders/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/WorkOrders/HtmlView.php new file mode 100644 index 0000000..06d57c4 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/View/WorkOrders/HtmlView.php @@ -0,0 +1,52 @@ +get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + + $this->filters = [ + 'status' => $input->getString('filter_status', ''), + 'trade' => $input->getString('filter_trade', ''), + 'date' => $input->getString('filter_date', ''), + 'search' => $input->getString('filter_search', ''), + ]; + + $query = $db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, loc.address, loc.city, t_cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id') + ->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id') + ->order('wo.created DESC'); + + if ($this->filters['status']) $query->where($db->quoteName('wo.status') . ' = ' . $db->quote($this->filters['status'])); + if ($this->filters['trade']) $query->where($db->quoteName('wo.trade') . ' = ' . $db->quote($this->filters['trade'])); + if ($this->filters['date']) $query->where($db->quoteName('wo.scheduled_date') . ' = ' . $db->quote($this->filters['date'])); + if ($this->filters['search']) { + $like = $db->quote('%' . $db->escape($this->filters['search'], true) . '%'); + $query->where('(wo.wo_number LIKE ' . $like . ' OR cd.name LIKE ' . $like . ')'); + } + + $db->setQuery($query, 0, 100); + $this->orders = $db->loadObjectList() ?: []; + + ToolbarHelper::title('Field Service — Work Orders', 'icon-wrench'); + ToolbarHelper::addNew('workorders.add'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/admin/tmpl/dashboard/default.php b/source/packages/com_mokosuitefield/admin/tmpl/dashboard/default.php new file mode 100644 index 0000000..ecd06c8 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/tmpl/dashboard/default.php @@ -0,0 +1,33 @@ +stats; +$board = $this->dispatchBoard; +$unassigned = $this->unassigned; +$urgent = $this->urgent; +?> +
+
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 new file mode 100644 index 0000000..4cf6a08 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/tmpl/dispatch/default.php @@ -0,0 +1,10 @@ +board;$s=$this->stats; +?> +
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 new file mode 100644 index 0000000..12ec110 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/tmpl/equipment/default.php @@ -0,0 +1,10 @@ +equipment;$due=$this->serviceDue; +?> +
equipment due
+ + + + +
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 new file mode 100644 index 0000000..13e6bb3 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/tmpl/serviceagreements/default.php @@ -0,0 +1,8 @@ +agreements; $rev=$this->revenue; ?> +
active_agreements; ?>
Active Agreements
$annual_recurring,0); ?>
Annual Recurring
$monthly_recurring,0); ?>
Monthly
+ + + + + +
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 new file mode 100644 index 0000000..8a25af9 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/tmpl/technicians/default.php @@ -0,0 +1,7 @@ +technicians; $statusColors=["available"=>"success","dispatched"=>"info","en_route"=>"warning","on_site"=>"primary","off_duty"=>"secondary","on_leave"=>"dark"]; ?> + + + + + +
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 new file mode 100644 index 0000000..4ec6984 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/tmpl/vehicles/default.php @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..00b7a70 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/tmpl/workorders/default.php @@ -0,0 +1,34 @@ +orders; +$f = $this->filters; +$statusColors = ['new'=>'secondary','dispatched'=>'info','en_route'=>'warning','on_site'=>'primary','in_progress'=>'primary','parts_needed'=>'danger','completed'=>'success','invoiced'=>'dark','cancelled'=>'danger']; +$priorityColors = ['emergency'=>'danger','urgent'=>'warning','high'=>'info','normal'=>'primary','low'=>'secondary','scheduled'=>'dark']; +?> +
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
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 new file mode 100644 index 0000000..881eece --- /dev/null +++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldEquipmentController.php @@ -0,0 +1,178 @@ +getIdentity(); + if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) { + http_response_code(403); + echo json_encode(['error' => 'Access denied.']); + Factory::getApplication()->close(); + } + } + + public function listEquipment(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + + $query = $db->getQuery(true) + ->select('eq.*, loc.name AS location_name, loc.address') + ->select('cd.name AS customer_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'eq')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = eq.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = eq.contact_id') + ->order('eq.name ASC'); + + $type = $input->getString('type', ''); + if ($type) $query->where($db->quoteName('eq.type') . ' = ' . $db->quote($type)); + + $status = $input->getString('status', ''); + if ($status) $query->where($db->quoteName('eq.status') . ' = ' . $db->quote($status)); + + $locationId = $input->getInt('location_id', 0); + if ($locationId) $query->where('eq.location_id = ' . $locationId); + + $db->setQuery($query, 0, 100); + $this->sendJson($db->loadObjectList() ?: []); + } + + public function getEquipment(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $id = Factory::getApplication()->getInput()->getInt('id', 0); + + $db->setQuery($db->getQuery(true) + ->select('eq.*, loc.name AS location_name, loc.address, cd.name AS customer_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'eq')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = eq.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = eq.contact_id') + ->where('eq.id = ' . $id)); + $equipment = $db->loadObject(); + + if (!$equipment) { + http_response_code(404); + $this->sendJson(['error' => 'Equipment not found']); + return; + } + + // Service history + $db->setQuery($db->getQuery(true) + ->select('wo.id, wo.wo_number, wo.description, wo.status, wo.completed_at, wo.trade') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->where('wo.equipment_id = ' . $id) + ->order('wo.scheduled_date DESC'), 0, 20); + $equipment->service_history = $db->loadObjectList() ?: []; + + $this->sendJson($equipment); + } + + public function listVehicles(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('v.*, t_cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_vehicles', 'v')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.technician_id') + ->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id') + ->order('v.vehicle_name ASC')); + + $this->sendJson($db->loadObjectList() ?: []); + } + + public function truckStock(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $vehicleId = Factory::getApplication()->getInput()->getInt('id', 0); + + $db->setQuery($db->getQuery(true) + ->select('ts.*, p.title AS product_name') + ->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts')) + ->join('LEFT', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id') + ->where('ts.vehicle_id = ' . $vehicleId) + ->order('p.title ASC')); + + $this->sendJson($db->loadObjectList() ?: []); + } + + public function listAgreements(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + + $query = $db->getQuery(true) + ->select('sa.*, cd.name AS customer_name, loc.address') + ->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = sa.location_id') + ->order('sa.end_date ASC'); + + $status = $input->getString('status', ''); + if ($status) $query->where($db->quoteName('sa.status') . ' = ' . $db->quote($status)); + + $db->setQuery($query, 0, 100); + $this->sendJson($db->loadObjectList() ?: []); + } + + public function getAgreement(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $id = Factory::getApplication()->getInput()->getInt('id', 0); + + $db->setQuery($db->getQuery(true) + ->select('sa.*, cd.name AS customer_name, loc.name AS location_name, loc.address') + ->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = sa.location_id') + ->where('sa.id = ' . $id)); + $agreement = $db->loadObject(); + + if (!$agreement) { + http_response_code(404); + $this->sendJson(['error' => 'Agreement not found']); + return; + } + + // Work orders under this agreement + $db->setQuery($db->getQuery(true) + ->select('wo.id, wo.wo_number, wo.description, wo.status, wo.scheduled_date, wo.trade') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->where('wo.agreement_id = ' . $id) + ->order('wo.scheduled_date DESC'), 0, 30); + $agreement->work_orders = $db->loadObjectList() ?: []; + + $this->sendJson($agreement); + } + + private function sendJson(mixed $data): void + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + Factory::getApplication()->close(); + } +} diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldEstimatesController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldEstimatesController.php new file mode 100644 index 0000000..5e598c9 --- /dev/null +++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldEstimatesController.php @@ -0,0 +1,150 @@ +getIdentity(); + if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) { + http_response_code(403); + echo json_encode(['error' => 'Access denied.']); + Factory::getApplication()->close(); + } + } + + public function listEstimates(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + + $query = $db->getQuery(true) + ->select('e.*, cd.name AS customer_name, loc.address') + ->from($db->quoteName('#__mokosuitefield_estimates', 'e')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id') + ->order('e.created DESC'); + + $status = $input->getString('status', ''); + if ($status) $query->where($db->quoteName('e.status') . ' = ' . $db->quote($status)); + + $techId = $input->getInt('technician_id', 0); + if ($techId) $query->where('e.technician_id = ' . $techId); + + $db->setQuery($query, 0, 100); + $this->sendJson($db->loadObjectList() ?: []); + } + + public function createEstimate(): void + { + $this->requireAuth('core.create'); + $input = Factory::getApplication()->getInput(); + + $estimateId = \Moko\Plugin\System\MokoSuiteField\Helper\EstimateHelper::createEstimate( + $input->getInt('contact_id', 0), + $input->getInt('location_id', 0), + $input->getString('trade', 'general'), + $input->getString('description', ''), + json_decode($input->getString('line_items', '[]'), true) ?: [] + ); + + $this->sendJson(['success' => true, 'estimate_id' => $estimateId]); + } + + public function updateStatus(): void + { + $this->requireAuth('core.edit'); + $input = Factory::getApplication()->getInput(); + $id = $input->getInt('id', 0); + $status = $input->getString('status', ''); + + if (!in_array($status, ['sent', 'approved', 'rejected', 'expired'])) { + http_response_code(400); + $this->sendJson(['error' => 'Invalid status']); + return; + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + $update = (object) [ + 'id' => $id, + 'status' => $status, + ]; + + if ($status === 'approved') { + $update->approved_at = Factory::getDate()->toSql(); + } + + $db->updateObject('#__mokosuitefield_estimates', $update, 'id'); + $this->sendJson(['success' => true]); + } + + public function convertToWorkOrder(): void + { + $this->requireAuth('core.create'); + $id = Factory::getApplication()->getInput()->getInt('id', 0); + + $woId = \Moko\Plugin\System\MokoSuiteField\Helper\EstimateHelper::convertToWorkOrder($id); + + if (!$woId) { + http_response_code(400); + $this->sendJson(['error' => 'Could not convert estimate']); + return; + } + + $this->sendJson(['success' => true, 'work_order_id' => $woId]); + } + + public function getRoute(): void + { + $this->requireAuth('core.manage'); + $techId = Factory::getApplication()->getInput()->getInt('tech_id', 0); + $date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d')); + + $route = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::getTechRoute($techId, $date); + $metrics = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::estimateRouteMetrics($techId, $date); + + $this->sendJson([ + 'route' => $route, + 'metrics' => $metrics, + ]); + } + + public function optimizeRoute(): void + { + $this->requireAuth('core.manage'); + $techId = Factory::getApplication()->getInput()->getInt('tech_id', 0); + $date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d')); + + $optimized = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::optimizeRoute($techId, $date); + $metrics = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::estimateRouteMetrics($techId, $date); + + $this->sendJson([ + 'route' => $optimized, + 'metrics' => $metrics, + ]); + } + + private function sendJson(mixed $data): void + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + Factory::getApplication()->close(); + } +} diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldMobileController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldMobileController.php new file mode 100644 index 0000000..f433e4c --- /dev/null +++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldMobileController.php @@ -0,0 +1,252 @@ +getIdentity(); + if (!$user || $user->guest) { + http_response_code(401); + echo json_encode(['error' => 'Authentication required.']); + Factory::getApplication()->close(); + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true) + ->select('t.*, cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('INNER', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->where('cd.user_id = ' . (int) $user->id)); + $tech = $db->loadObject(); + + if (!$tech) { + http_response_code(403); + echo json_encode(['error' => 'No technician profile.']); + Factory::getApplication()->close(); + } + + return $tech; + } + + public function myJobs(): void + { + $tech = $this->requireTech(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $today = date('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, cd.telephone AS customer_phone') + ->select('loc.address, loc.city, loc.state, loc.zip, loc.latitude, loc.longitude, loc.access_notes') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->where('wo.technician_id = ' . (int) $tech->id) + ->where('(wo.scheduled_date = ' . $db->quote($today) . ' OR wo.status IN (' . $db->quote('dispatched') . ',' . $db->quote('en_route') . ',' . $db->quote('on_site') . ',' . $db->quote('in_progress') . '))') + ->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ') ASC')); + + $this->sendJson($db->loadObjectList() ?: []); + } + + public function updateStatus(): void + { + $tech = $this->requireTech(); + $input = Factory::getApplication()->getInput(); + + \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::updateStatus( + $input->getInt('work_order_id', 0), + $input->getString('status', ''), + $input->getFloat('lat', 0) ?: null, + $input->getFloat('lng', 0) ?: null + ); + + $this->sendJson(['message' => 'Status updated.']); + } + + public function uploadPhoto(): void + { + $tech = $this->requireTech(); + $input = Factory::getApplication()->getInput(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $woId = $input->getInt('work_order_id', 0); + $photoType = $input->getString('photo_type', 'other'); + $caption = $input->getString('caption', ''); + $lat = $input->getFloat('lat', 0) ?: null; + $lng = $input->getFloat('lng', 0) ?: null; + + // Handle file upload + $file = $input->files->get('photo'); + if (!$file || $file['error'] !== 0) { + http_response_code(400); + $this->sendJson(['error' => 'No photo uploaded.']); + return; + } + + $uploadDir = 'media/com_mokosuitefield/photos/' . date('Y/m/'); + if (!is_dir(JPATH_ROOT . '/' . $uploadDir)) { + mkdir(JPATH_ROOT . '/' . $uploadDir, 0755, true); + } + + $ext = pathinfo($file['name'], PATHINFO_EXTENSION); + $filename = 'wo' . $woId . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext; + $filePath = $uploadDir . $filename; + + move_uploaded_file($file['tmp_name'], JPATH_ROOT . '/' . $filePath); + + $db->insertObject('#__mokosuitefield_wo_photos', (object) [ + 'work_order_id' => $woId, + 'file_path' => $filePath, + 'photo_type' => $photoType, + 'caption' => $caption, + 'latitude' => $lat, + 'longitude' => $lng, + 'taken_at' => Factory::getDate()->toSql(), + 'uploaded_by' => Factory::getApplication()->getIdentity()->id, + 'created' => Factory::getDate()->toSql(), + ], 'id'); + + $this->sendJson(['message' => 'Photo uploaded.', 'path' => $filePath]); + } + + public function startTime(): void + { + $tech = $this->requireTech(); + $input = Factory::getApplication()->getInput(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->insertObject('#__mokosuitefield_time_entries', (object) [ + 'work_order_id' => $input->getInt('work_order_id', 0), + 'technician_id' => $tech->id, + 'start_time' => Factory::getDate()->toSql(), + 'is_travel' => $input->getInt('is_travel', 0), + 'rate' => $tech->hourly_rate, + 'created' => Factory::getDate()->toSql(), + ], 'id'); + + $this->sendJson(['message' => 'Timer started.']); + } + + public function stopTime(): void + { + $tech = $this->requireTech(); + $input = Factory::getApplication()->getInput(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $entryId = $input->getInt('entry_id', 0); + $now = Factory::getDate()->toSql(); + + $db->setQuery($db->getQuery(true)->select('start_time, rate')->from('#__mokosuitefield_time_entries')->where('id = ' . $entryId)); + $entry = $db->loadObject(); + + if (!$entry) { + http_response_code(404); + $this->sendJson(['error' => 'Time entry not found.']); + return; + } + + $hours = round((strtotime($now) - strtotime($entry->start_time)) / 3600, 2); + + $db->updateObject('#__mokosuitefield_time_entries', (object) [ + 'id' => $entryId, + 'end_time' => $now, + 'hours' => $hours, + ], 'id'); + + $this->sendJson(['message' => 'Timer stopped.', 'hours' => $hours]); + } + + public function logPart(): void + { + $tech = $this->requireTech(); + $input = Factory::getApplication()->getInput(); + + $productId = $input->getInt('product_id', 0); + $qty = $input->getFloat('quantity', 1); + $woId = $input->getInt('work_order_id', 0); + + // Deduct from truck stock + \Moko\Plugin\System\MokoSuiteField\Helper\TruckStockHelper::usePart( + (int) $tech->vehicle_id, $productId, $qty + ); + + // Add as WO line item + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true)->select('name, cost_price, price')->from('#__mokosuite_crm_products')->where('id = ' . $productId)); + $product = $db->loadObject(); + + if ($product) { + $db->insertObject('#__mokosuitefield_wo_items', (object) [ + 'work_order_id' => $woId, + 'item_type' => 'part', + 'product_id' => $productId, + 'description' => $product->name, + 'quantity' => $qty, + 'unit_price' => (float) $product->price, + 'line_total' => $qty * (float) $product->price, + 'from_truck_stock' => 1, + ]); + } + + $this->sendJson(['message' => 'Part logged.']); + } + + public function gpsHeartbeat(): void + { + $tech = $this->requireTech(); + $input = Factory::getApplication()->getInput(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->updateObject('#__mokosuitefield_technicians', (object) [ + 'id' => $tech->id, + 'current_lat' => $input->getFloat('lat', 0), + 'current_lng' => $input->getFloat('lng', 0), + 'last_location_update' => Factory::getDate()->toSql(), + ], 'id'); + + $this->sendJson(['message' => 'Location updated.']); + } + + public function equipmentLookup(): void + { + $this->requireTech(); + $qr = Factory::getApplication()->getInput()->getString('qr', ''); + + $equipment = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getByQrCode($qr); + + if (!$equipment) { + http_response_code(404); + $this->sendJson(['error' => 'Equipment not found.']); + return; + } + + $this->sendJson($equipment); + } + + private function sendJson(mixed $data): void + { + $app = Factory::getApplication(); + $app->getDocument()->setMimeEncoding('application/json'); + echo json_encode(['data' => $data], JSON_THROW_ON_ERROR); + $app->close(); + } +} diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldWorkOrderController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldWorkOrderController.php new file mode 100644 index 0000000..d8fbe4c --- /dev/null +++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldWorkOrderController.php @@ -0,0 +1,131 @@ +getIdentity(); + if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) { + http_response_code(403); + echo json_encode(['error' => 'Access denied.']); + Factory::getApplication()->close(); + } + } + + public function list(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + $status = $input->getString('status', ''); + $techId = $input->getInt('technician_id', 0); + $date = $input->getString('date', ''); + + $query = $db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, loc.address, loc.city, t_cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id') + ->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id') + ->order('wo.scheduled_date ASC, wo.scheduled_time_start ASC'); + + if ($status) $query->where($db->quoteName('wo.status') . ' = ' . $db->quote($status)); + if ($techId) $query->where('wo.technician_id = ' . $techId); + if ($date) $query->where('wo.scheduled_date = ' . $db->quote($date)); + + $db->setQuery($query, 0, 100); + $this->sendJson($db->loadObjectList() ?: []); + } + + public function create(): void + { + $this->requireAuth('core.create'); + $input = Factory::getApplication()->getInput(); + + $woId = \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::create( + $input->getInt('contact_id', 0), + $input->getString('trade', 'general'), + $input->getString('description', ''), + [ + 'location_id' => $input->getInt('location_id', 0), + 'priority' => $input->getString('priority', 'normal'), + 'category' => $input->getString('category', ''), + 'scheduled_date' => $input->getString('scheduled_date', ''), + 'time_start' => $input->getString('time_start', ''), + 'source' => $input->getString('source', 'phone'), + ] + ); + + $this->sendJson(['id' => $woId, 'message' => 'Work order created.']); + } + + public function updateStatus(): void + { + $input = Factory::getApplication()->getInput(); + + \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::updateStatus( + $input->getInt('id', 0), + $input->getString('status', ''), + $input->getFloat('lat', 0) ?: null, + $input->getFloat('lng', 0) ?: null + ); + + $this->sendJson(['message' => 'Status updated.']); + } + + public function dispatchToTech(): void + { + $this->requireAuth('field.dispatch'); + $input = Factory::getApplication()->getInput(); + + \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::dispatch( + $input->getInt('work_order_id', 0), + $input->getInt('technician_id', 0) + ); + + $this->sendJson(['message' => 'Dispatched.']); + } + + public function board(): void + { + $date = Factory::getApplication()->getInput()->getString('date', ''); + $this->sendJson(\Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getDispatchBoard($date)); + } + + public function availableTechs(): void + { + $input = Factory::getApplication()->getInput(); + $tech = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::findBestTech( + $input->getString('trade', 'general'), + $input->getString('zip', '') + ); + + $this->sendJson($tech ? [$tech] : []); + } + + private function sendJson(mixed $data): void + { + $app = Factory::getApplication(); + $app->getDocument()->setMimeEncoding('application/json'); + echo json_encode(['data' => $data], JSON_THROW_ON_ERROR); + $app->close(); + } +} diff --git a/source/packages/com_mokosuitefield/media/css/field.css b/source/packages/com_mokosuitefield/media/css/field.css new file mode 100644 index 0000000..a22318a --- /dev/null +++ b/source/packages/com_mokosuitefield/media/css/field.css @@ -0,0 +1,9 @@ +/* MokoSuite Field Service Styles */ +.dispatch-board .tech-card { border-left: 4px solid #198754; } +.dispatch-board .tech-card.dispatched { border-left-color: #0d6efd; } +.dispatch-board .tech-card.on-site { border-left-color: #ffc107; } +.wo-priority-emergency { background-color: rgba(220, 53, 69, 0.1) !important; } +.wo-priority-urgent { background-color: rgba(255, 193, 7, 0.08) !important; } +.tech-mobile .current-job { border: 2px solid #0d6efd; } +@media (max-width: 768px) { .tech-mobile .card { margin-bottom: 0.5rem; } } +@media print { .btn, .toolbar { display: none !important; } } diff --git a/source/packages/com_mokosuitefield/media/js/dispatch.js b/source/packages/com_mokosuitefield/media/js/dispatch.js new file mode 100644 index 0000000..f0d9d81 --- /dev/null +++ b/source/packages/com_mokosuitefield/media/js/dispatch.js @@ -0,0 +1,6 @@ +document.addEventListener('DOMContentLoaded', function() { + // Auto-refresh dispatch board every 30 seconds + if (document.querySelector('.dispatch-board')) { + setInterval(function() { location.reload(); }, 30000); + } +}); diff --git a/source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php b/source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php new file mode 100644 index 0000000..9ca2166 --- /dev/null +++ b/source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php @@ -0,0 +1,8 @@ +getParams('com_mokosuitefield'); + $this->companyName = $params->get('company_name', $app->get('sitename')); + + $this->trades = [ + 'plumbing' => 'Plumbing', + 'electrical' => 'Electrical', + 'hvac' => 'HVAC / Heating & Cooling', + 'appliance' => 'Appliance Repair', + 'general' => 'General Maintenance', + 'carpentry' => 'Carpentry', + 'painting' => 'Painting', + 'roofing' => 'Roofing', + 'landscaping' => 'Landscaping', + 'locksmith' => 'Locksmith', + ]; + + if ($app->getInput()->getMethod() === 'POST' && \Joomla\CMS\Session\Session::checkToken()) { + $input = $app->getInput(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $name = $input->getString('name', ''); + $email = $input->getString('email', ''); + $phone = $input->getString('phone', ''); + $address = $input->getString('address', ''); + $trade = $input->getString('trade', 'general'); + $desc = $input->getString('description', ''); + $priority = $input->getString('priority', 'normal'); + + if ($name && $phone && $desc) { + // Find or create contact + $db->setQuery($db->getQuery(true)->select('id')->from('#__contact_details') + ->where($db->quoteName('telephone') . ' = ' . $db->quote($phone))); + $contactId = (int) $db->loadResult(); + + if (!$contactId) { + $db->insertObject('#__contact_details', (object) [ + 'name' => $name, 'email_to' => $email, 'telephone' => $phone, + 'address' => $address, 'published' => 1, 'created' => Factory::getDate()->toSql(), + ], 'id'); + $contactId = $db->insertid(); + } + + // Create work order + \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::create( + (int) $contactId, $trade, $desc, [ + 'priority' => $priority, + 'source' => 'website', + ] + ); + + $this->submitted = true; + } + } + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/site/src/View/CustomerPortal/HtmlView.php b/source/packages/com_mokosuitefield/site/src/View/CustomerPortal/HtmlView.php new file mode 100644 index 0000000..1aef77c --- /dev/null +++ b/source/packages/com_mokosuitefield/site/src/View/CustomerPortal/HtmlView.php @@ -0,0 +1,89 @@ +getIdentity(); + + if (!$user || $user->guest) { + $app->redirect('index.php?option=com_users&view=login'); + return; + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Resolve contact from Joomla user + $db->setQuery($db->getQuery(true)->select('id')->from('#__contact_details')->where('user_id = ' . (int) $user->id)); + $this->contactId = (int) $db->loadResult(); + + if (!$this->contactId) { + $app->enqueueMessage('No customer profile found.', 'warning'); + return; + } + + // Active work orders (in-progress) + $db->setQuery($db->getQuery(true) + ->select('wo.*, t_cd.name AS tech_name, t.trade AS tech_trade, loc.address') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id') + ->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->where('wo.contact_id = ' . $this->contactId) + ->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('completed') . ',' . $db->quote('invoiced') . ',' . $db->quote('cancelled') . ')') + ->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ') ASC')); + $this->activeOrders = $db->loadObjectList() ?: []; + + // Recent completed orders (last 10) + $db->setQuery($db->getQuery(true) + ->select('wo.wo_number, wo.trade, wo.category, wo.total, wo.actual_departure AS completed_at, wo.work_performed') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->where('wo.contact_id = ' . $this->contactId) + ->where($db->quoteName('wo.status') . ' IN (' . $db->quote('completed') . ',' . $db->quote('invoiced') . ')') + ->order('wo.actual_departure DESC'), 0, 10); + $this->orderHistory = $db->loadObjectList() ?: []; + + // Equipment at customer locations + $db->setQuery($db->getQuery(true) + ->select('e.*, loc.address') + ->from($db->quoteName('#__mokosuitefield_equipment', 'e')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id') + ->where('e.contact_id = ' . $this->contactId) + ->order('e.equipment_type ASC')); + $this->equipment = $db->loadObjectList() ?: []; + + // Active service agreements + $this->agreements = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getActiveAgreements($this->contactId); + + // Next scheduled service + $db->setQuery($db->getQuery(true) + ->select('wo.wo_number, wo.scheduled_date, wo.scheduled_time_start, wo.trade, wo.category') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->where('wo.contact_id = ' . $this->contactId) + ->where($db->quoteName('wo.scheduled_date') . ' >= CURDATE()') + ->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ')') + ->order('wo.scheduled_date ASC, wo.scheduled_time_start ASC'), 0, 1); + $this->nextService = $db->loadObject(); + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/site/src/View/EstimateView/HtmlView.php b/source/packages/com_mokosuitefield/site/src/View/EstimateView/HtmlView.php new file mode 100644 index 0000000..6c6f8eb --- /dev/null +++ b/source/packages/com_mokosuitefield/site/src/View/EstimateView/HtmlView.php @@ -0,0 +1,90 @@ +getInput(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $token = $input->getString('token', ''); + + if (!$token) { + $app->enqueueMessage('Invalid estimate link.', 'warning'); + parent::display($tpl); + return; + } + + // Load estimate by token + $db->setQuery($db->getQuery(true) + ->select('e.*, cd.name AS customer_name, cd.email_to, cd.telephone') + ->select('l.address, l.city, l.state, l.zip') + ->from($db->quoteName('#__mokosuitefield_estimates', 'e')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'l') . ' ON l.id = e.location_id') + ->where($db->quoteName('e.approval_token') . ' = ' . $db->quote($token))); + $this->estimate = $db->loadObject(); + + if (!$this->estimate) { + $app->enqueueMessage('Estimate not found or link expired.', 'warning'); + parent::display($tpl); + return; + } + + // Load line items + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitefield_estimate_items') + ->where('estimate_id = ' . (int) $this->estimate->id) + ->order('ordering ASC')); + $this->lineItems = $db->loadObjectList() ?: []; + + // Handle approval/rejection (CSRF check not required — token-based public page) + if ($input->getMethod() === 'POST' && \Joomla\CMS\Session\Session::checkToken()) { + $action = $input->getString('action', ''); + + if ($action === 'approve' && $this->estimate->status === 'sent') { + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitefield_estimates') + ->set($db->quoteName('status') . ' = ' . $db->quote('approved')) + ->set($db->quoteName('approved_at') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->set($db->quoteName('customer_signature') . ' = ' . $db->quote($input->getString('signature', ''))) + ->where('id = ' . (int) $this->estimate->id)); + $db->execute(); + + $this->actioned = true; + $this->actionResult = 'approved'; + $this->estimate->status = 'approved'; + } elseif ($action === 'reject' && $this->estimate->status === 'sent') { + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitefield_estimates') + ->set($db->quoteName('status') . ' = ' . $db->quote('rejected')) + ->set($db->quoteName('rejection_reason') . ' = ' . $db->quote($input->getString('reason', ''))) + ->where('id = ' . (int) $this->estimate->id)); + $db->execute(); + + $this->actioned = true; + $this->actionResult = 'rejected'; + $this->estimate->status = 'rejected'; + } + } + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/site/src/View/TechMobile/HtmlView.php b/source/packages/com_mokosuitefield/site/src/View/TechMobile/HtmlView.php new file mode 100644 index 0000000..7695292 --- /dev/null +++ b/source/packages/com_mokosuitefield/site/src/View/TechMobile/HtmlView.php @@ -0,0 +1,70 @@ +getIdentity(); + + if (!$user || $user->guest) { + $app->redirect('index.php?option=com_users&view=login'); + return; + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Find technician record for logged-in user + $db->setQuery($db->getQuery(true) + ->select('t.*, cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('INNER', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->join('INNER', $db->quoteName('#__users', 'u') . ' ON u.id = ' . (int) $user->id) + ->where('cd.user_id = ' . (int) $user->id)); + $this->tech = $db->loadObject(); + + if (!$this->tech) { + $app->enqueueMessage('No technician profile found for your account.', 'warning'); + return; + } + + $today = date('Y-m-d'); + + // Today's assigned jobs + $db->setQuery($db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, cd.telephone AS customer_phone') + ->select('loc.address, loc.city, loc.state, loc.zip, loc.latitude, loc.longitude, loc.access_notes') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->where('wo.technician_id = ' . (int) $this->tech->id) + ->where('(wo.scheduled_date = ' . $db->quote($today) . ' OR (wo.scheduled_date IS NULL AND wo.status NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ',' . $db->quote('invoiced') . ')))') + ->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ',' . $db->quote('low') . ') ASC, wo.scheduled_time_start ASC')); + $this->todayJobs = $db->loadObjectList() ?: []; + + // Current active job (en_route, on_site, or in_progress) + foreach ($this->todayJobs as $job) { + if (in_array($job->status, ['en_route', 'on_site', 'in_progress'])) { + $this->currentJob = $job; + break; + } + } + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/site/tmpl/bookservice/default.php b/source/packages/com_mokosuitefield/site/tmpl/bookservice/default.php new file mode 100644 index 0000000..237af91 --- /dev/null +++ b/source/packages/com_mokosuitefield/site/tmpl/bookservice/default.php @@ -0,0 +1,33 @@ +companyName; +$trades = $this->trades; +if ($this->submitted) : ?> +

Service Request Received

We will contact you shortly to schedule your appointment.

+ +
+

Request Service

+

escape($company); ?>

+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
diff --git a/source/packages/com_mokosuitefield/site/tmpl/customerportal/default.php b/source/packages/com_mokosuitefield/site/tmpl/customerportal/default.php new file mode 100644 index 0000000..9f7da25 --- /dev/null +++ b/source/packages/com_mokosuitefield/site/tmpl/customerportal/default.php @@ -0,0 +1,14 @@ +contactId) return; +$active=$this->activeOrders;$history=$this->orderHistory;$next=$this->nextService; +?> +

My Service Portal

+
Next Service
trade); ?>scheduled_date)); ?>
+
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 new file mode 100644 index 0000000..50fa071 --- /dev/null +++ b/source/packages/com_mokosuitefield/site/tmpl/estimateview/default.php @@ -0,0 +1,96 @@ +estimate) return; +$e = $this->estimate; +$subtotal = 0; +foreach ($this->lineItems as $li) { $subtotal += (float) $li->line_total; } +$tax = round($subtotal * 0.07, 2); +$total = $subtotal + $tax; +?> +
+ actioned) : ?> +
+

Estimate actionResult); ?>

+

actionResult === 'approved' ? 'Thank you! We will schedule the work shortly.' : 'The estimate has been declined.'; ?>

+
+ + +
+
+

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 new file mode 100644 index 0000000..691ee15 --- /dev/null +++ b/source/packages/com_mokosuitefield/site/tmpl/techmobile/default.php @@ -0,0 +1,56 @@ +tech; +$jobs = $this->todayJobs; +$current = $this->currentJob; +if (!$tech) return; +$statusColors = ['new'=>'secondary','dispatched'=>'info','en_route'=>'warning','on_site'=>'primary','in_progress'=>'primary','parts_needed'=>'danger','completed'=>'success']; +?> +
+
+

escape($tech->tech_name); ?>

+status)); ?> +
+ + +
+
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 new file mode 100644 index 0000000..c507f86 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field" +PLG_SYSTEM_MOKOSUITEFIELD_DESC="MokoSuite Field Service system plugin - database schema and helpers." diff --git a/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini b/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini new file mode 100644 index 0000000..26bf5c4 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field" +PLG_SYSTEM_MOKOSUITEFIELD_DESC="MokoSuite Field Service system plugin." diff --git a/source/packages/plg_system_mokosuitefield/sql/install.mysql.sql b/source/packages/plg_system_mokosuitefield/sql/install.mysql.sql new file mode 100644 index 0000000..e0a9d8d --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/sql/install.mysql.sql @@ -0,0 +1,332 @@ +-- +-- MokoSuite Field Service Tables +-- + +-- ============================================================ +-- Technicians — extends CRM contacts / HRM employees +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_technicians` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `contact_id` INT NOT NULL COMMENT 'FK to #__contact_details', + `employee_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to HRM employees if installed', + `tech_number` VARCHAR(20) NOT NULL DEFAULT '', + `status` ENUM('available','dispatched','en_route','on_site','off_duty','on_leave') NOT NULL DEFAULT 'available', + `trade` ENUM('general','plumbing','electrical','hvac','appliance','carpentry','painting','roofing','landscaping','pest_control','locksmith','multi_trade') NOT NULL DEFAULT 'general', + `license_number` VARCHAR(100) DEFAULT NULL COMMENT 'Trade license (e.g., master plumber)', + `license_expiry` DATE DEFAULT NULL, + `certifications` JSON DEFAULT NULL COMMENT '["EPA 608","backflow certified","journeyman electrician"]', + `skills` JSON DEFAULT NULL, + `hourly_rate` DECIMAL(10,2) DEFAULT NULL, + `overtime_rate` DECIMAL(10,2) DEFAULT NULL, + `max_daily_jobs` INT UNSIGNED NOT NULL DEFAULT 8, + `service_radius_miles` INT UNSIGNED NOT NULL DEFAULT 30, + `home_zip` VARCHAR(10) DEFAULT NULL, + `home_lat` DECIMAL(10,7) DEFAULT NULL, + `home_lng` DECIMAL(10,7) DEFAULT NULL, + `current_lat` DECIMAL(10,7) DEFAULT NULL, + `current_lng` DECIMAL(10,7) DEFAULT NULL, + `last_location_update` DATETIME DEFAULT NULL, + `vehicle_id` INT UNSIGNED DEFAULT NULL, + `notes` TEXT, + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_contact` (`contact_id`), + KEY `idx_status` (`status`), + KEY `idx_trade` (`trade`), + KEY `idx_vehicle` (`vehicle_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Service Locations — customer property/site records +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_locations` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `contact_id` INT NOT NULL COMMENT 'FK to CRM contact (property owner)', + `name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'e.g., "Main Office", "123 Oak St Residence"', + `address` TEXT NOT NULL, + `city` VARCHAR(100) NOT NULL DEFAULT '', + `state` VARCHAR(50) NOT NULL DEFAULT '', + `zip` VARCHAR(20) NOT NULL DEFAULT '', + `latitude` DECIMAL(10,7) DEFAULT NULL, + `longitude` DECIMAL(10,7) DEFAULT NULL, + `property_type` ENUM('residential','commercial','industrial','multi_family','government','other') NOT NULL DEFAULT 'residential', + `access_notes` TEXT COMMENT 'Gate codes, parking, pet warnings, key location', + `equipment_notes` TEXT COMMENT 'Known equipment at this location', + `service_history_count` INT UNSIGNED NOT NULL DEFAULT 0, + `last_service_date` DATE DEFAULT NULL, + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_contact` (`contact_id`), + KEY `idx_zip` (`zip`), + KEY `idx_type` (`property_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Work Orders — the core job record +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_work_orders` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `wo_number` VARCHAR(30) NOT NULL DEFAULT '', + `contact_id` INT NOT NULL COMMENT 'Customer', + `location_id` INT UNSIGNED DEFAULT NULL, + `technician_id` INT UNSIGNED DEFAULT NULL, + `trade` ENUM('general','plumbing','electrical','hvac','appliance','carpentry','painting','roofing','landscaping','pest_control','locksmith','multi_trade') NOT NULL DEFAULT 'general', + `priority` ENUM('emergency','urgent','high','normal','low','scheduled') NOT NULL DEFAULT 'normal', + `status` ENUM('new','dispatched','en_route','on_site','in_progress','parts_needed','on_hold','completed','invoiced','cancelled') NOT NULL DEFAULT 'new', + `category` VARCHAR(100) DEFAULT NULL COMMENT 'e.g., "water heater", "panel upgrade", "AC repair"', + `description` TEXT NOT NULL, + `customer_po` VARCHAR(100) DEFAULT NULL COMMENT 'Customer PO number', + `scheduled_date` DATE DEFAULT NULL, + `scheduled_time_start` TIME DEFAULT NULL, + `scheduled_time_end` TIME DEFAULT NULL, + `actual_arrival` DATETIME DEFAULT NULL, + `actual_departure` DATETIME DEFAULT NULL, + `work_performed` TEXT, + `diagnosis` TEXT, + `parts_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `labor_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `tax` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `payment_status` ENUM('unpaid','partial','paid','waived') NOT NULL DEFAULT 'unpaid', + `payment_method` VARCHAR(50) DEFAULT NULL, + `customer_signature` TEXT COMMENT 'Base64 signature data', + `customer_signed_at` DATETIME DEFAULT NULL, + `service_agreement_id` INT UNSIGNED DEFAULT NULL, + `invoice_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM invoices', + `deal_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM deals', + `source` ENUM('phone','website','walk_in','referral','service_agreement','emergency','recurring') NOT NULL DEFAULT 'phone', + `created_by` INT NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_wo_number` (`wo_number`), + KEY `idx_contact` (`contact_id`), + KEY `idx_location` (`location_id`), + KEY `idx_tech` (`technician_id`), + KEY `idx_status` (`status`), + KEY `idx_priority` (`priority`), + KEY `idx_trade` (`trade`), + KEY `idx_scheduled` (`scheduled_date`), + KEY `idx_agreement` (`service_agreement_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Work Order Line Items — parts and labor +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_wo_items` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `work_order_id` INT UNSIGNED NOT NULL, + `item_type` ENUM('labor','part','material','flat_rate','discount','permit','disposal') NOT NULL DEFAULT 'labor', + `product_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM products for parts', + `description` VARCHAR(500) NOT NULL, + `quantity` DECIMAL(10,2) NOT NULL DEFAULT 1.00, + `unit_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `line_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `taxable` TINYINT NOT NULL DEFAULT 1, + `from_truck_stock` TINYINT NOT NULL DEFAULT 0 COMMENT 'Taken from tech truck inventory', + `sort_order` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_wo` (`work_order_id`), + KEY `idx_type` (`item_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Work Order Photos — before/after, damage, equipment +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_wo_photos` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `work_order_id` INT UNSIGNED NOT NULL, + `file_path` VARCHAR(500) NOT NULL, + `thumbnail_path` VARCHAR(500) DEFAULT NULL, + `photo_type` ENUM('before','during','after','damage','equipment','permit','other') NOT NULL DEFAULT 'other', + `caption` VARCHAR(500) DEFAULT NULL, + `latitude` DECIMAL(10,7) DEFAULT NULL, + `longitude` DECIMAL(10,7) DEFAULT NULL, + `taken_at` DATETIME DEFAULT NULL, + `uploaded_by` INT NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_wo` (`work_order_id`), + KEY `idx_type` (`photo_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Service Agreements — recurring maintenance contracts +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_agreements` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `contact_id` INT NOT NULL, + `location_id` INT UNSIGNED DEFAULT NULL, + `agreement_number` VARCHAR(30) NOT NULL DEFAULT '', + `title` VARCHAR(255) NOT NULL, + `trade` ENUM('general','plumbing','electrical','hvac','appliance','multi_trade') NOT NULL DEFAULT 'general', + `agreement_type` ENUM('preventive','full_service','parts_only','labor_only','priority_response') NOT NULL DEFAULT 'preventive', + `visits_per_year` INT UNSIGNED NOT NULL DEFAULT 2, + `visits_used` INT UNSIGNED NOT NULL DEFAULT 0, + `annual_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `billing_frequency` ENUM('monthly','quarterly','semi_annual','annual') NOT NULL DEFAULT 'annual', + `start_date` DATE NOT NULL, + `end_date` DATE DEFAULT NULL, + `auto_renew` TINYINT NOT NULL DEFAULT 1, + `sla_response_hours` INT UNSIGNED DEFAULT NULL COMMENT 'Guaranteed response time', + `parts_discount_pct` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `labor_discount_pct` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `status` ENUM('active','expired','cancelled','pending_renewal') NOT NULL DEFAULT 'active', + `equipment_covered` JSON DEFAULT NULL COMMENT 'List of equipment IDs covered', + `notes` TEXT, + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_number` (`agreement_number`), + KEY `idx_contact` (`contact_id`), + KEY `idx_location` (`location_id`), + KEY `idx_status` (`status`), + KEY `idx_end` (`end_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Equipment — customer equipment tracked for service history +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_equipment` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `location_id` INT UNSIGNED NOT NULL, + `contact_id` INT NOT NULL, + `equipment_type` ENUM('water_heater','furnace','ac_unit','heat_pump','boiler','electrical_panel','generator','sump_pump','water_softener','tankless_heater','mini_split','rooftop_unit','compressor','chiller','other') NOT NULL DEFAULT 'other', + `make` VARCHAR(100) DEFAULT NULL, + `model` VARCHAR(100) DEFAULT NULL, + `serial_number` VARCHAR(100) DEFAULT NULL, + `install_date` DATE DEFAULT NULL, + `warranty_expiry` DATE DEFAULT NULL, + `last_service_date` DATE DEFAULT NULL, + `next_service_date` DATE DEFAULT NULL, + `condition` ENUM('excellent','good','fair','poor','needs_replacement') DEFAULT NULL, + `location_detail` VARCHAR(255) DEFAULT NULL COMMENT 'e.g., "basement", "roof", "garage"', + `refrigerant_type` VARCHAR(20) DEFAULT NULL COMMENT 'HVAC: R-410A, R-22, etc.', + `capacity` VARCHAR(50) DEFAULT NULL COMMENT 'e.g., "50 gal", "3 ton", "200 amp"', + `fuel_type` VARCHAR(20) DEFAULT NULL COMMENT 'gas, electric, oil, propane', + `notes` TEXT, + `photo_path` VARCHAR(500) DEFAULT NULL, + `qr_code` VARCHAR(100) DEFAULT NULL COMMENT 'QR code on equipment sticker for quick lookup', + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_location` (`location_id`), + KEY `idx_contact` (`contact_id`), + KEY `idx_type` (`equipment_type`), + KEY `idx_serial` (`serial_number`), + KEY `idx_qr` (`qr_code`), + KEY `idx_next_service` (`next_service_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Vehicles — service fleet tracking +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_vehicles` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `vehicle_number` VARCHAR(20) NOT NULL, + `make` VARCHAR(50) DEFAULT NULL, + `model` VARCHAR(50) DEFAULT NULL, + `year` INT UNSIGNED DEFAULT NULL, + `vin` VARCHAR(17) DEFAULT NULL, + `license_plate` VARCHAR(20) DEFAULT NULL, + `assigned_tech_id` INT UNSIGNED DEFAULT NULL, + `status` ENUM('active','maintenance','retired') NOT NULL DEFAULT 'active', + `mileage` INT UNSIGNED DEFAULT NULL, + `last_inspection` DATE DEFAULT NULL, + `next_inspection` DATE DEFAULT NULL, + `insurance_expiry` DATE DEFAULT NULL, + `gps_device_id` VARCHAR(100) DEFAULT NULL, + `notes` TEXT, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_tech` (`assigned_tech_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Truck Stock — parts inventory per vehicle +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_truck_stock` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `vehicle_id` INT UNSIGNED NOT NULL, + `product_id` INT UNSIGNED NOT NULL COMMENT 'FK to CRM products', + `quantity` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `min_quantity` DECIMAL(10,2) NOT NULL DEFAULT 1.00 COMMENT 'Reorder point', + `last_restocked` DATE DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_vehicle_product` (`vehicle_id`, `product_id`), + KEY `idx_vehicle` (`vehicle_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Dispatch Log — assignment and routing history +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_dispatch_log` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `work_order_id` INT UNSIGNED NOT NULL, + `technician_id` INT UNSIGNED NOT NULL, + `action` ENUM('assigned','accepted','rejected','en_route','arrived','completed','reassigned','cancelled') NOT NULL, + `notes` TEXT, + `latitude` DECIMAL(10,7) DEFAULT NULL, + `longitude` DECIMAL(10,7) DEFAULT NULL, + `created_by` INT NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_wo` (`work_order_id`), + KEY `idx_tech` (`technician_id`), + KEY `idx_action` (`action`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Estimates — on-site quoting +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_estimates` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `work_order_id` INT UNSIGNED DEFAULT NULL COMMENT 'Generated from a WO inspection', + `contact_id` INT NOT NULL, + `location_id` INT UNSIGNED DEFAULT NULL, + `estimate_number` VARCHAR(30) NOT NULL DEFAULT '', + `title` VARCHAR(255) NOT NULL, + `description` TEXT, + `parts_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `labor_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `tax` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `status` ENUM('draft','sent','viewed','accepted','declined','expired','converted') NOT NULL DEFAULT 'draft', + `valid_days` INT UNSIGNED NOT NULL DEFAULT 30, + `token` VARCHAR(64) DEFAULT NULL COMMENT 'Public view/accept token', + `accepted_at` DATETIME DEFAULT NULL, + `customer_signature` TEXT, + `converted_wo_id` INT UNSIGNED DEFAULT NULL COMMENT 'WO created from accepted estimate', + `created_by` INT NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_number` (`estimate_number`), + KEY `idx_contact` (`contact_id`), + KEY `idx_wo` (`work_order_id`), + KEY `idx_status` (`status`), + KEY `idx_token` (`token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Time Entries — tech labor tracking per work order +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_time_entries` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `work_order_id` INT UNSIGNED NOT NULL, + `technician_id` INT UNSIGNED NOT NULL, + `start_time` DATETIME NOT NULL, + `end_time` DATETIME DEFAULT NULL, + `hours` DECIMAL(5,2) DEFAULT NULL, + `is_overtime` TINYINT NOT NULL DEFAULT 0, + `is_travel` TINYINT NOT NULL DEFAULT 0, + `rate` DECIMAL(10,2) DEFAULT NULL, + `notes` TEXT, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_wo` (`work_order_id`), + KEY `idx_tech` (`technician_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql b/source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql new file mode 100644 index 0000000..e804a60 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql @@ -0,0 +1,12 @@ +DROP TABLE IF EXISTS `#__mokosuitefield_time_entries`; +DROP TABLE IF EXISTS `#__mokosuitefield_dispatch_log`; +DROP TABLE IF EXISTS `#__mokosuitefield_estimates`; +DROP TABLE IF EXISTS `#__mokosuitefield_truck_stock`; +DROP TABLE IF EXISTS `#__mokosuitefield_vehicles`; +DROP TABLE IF EXISTS `#__mokosuitefield_wo_photos`; +DROP TABLE IF EXISTS `#__mokosuitefield_wo_items`; +DROP TABLE IF EXISTS `#__mokosuitefield_work_orders`; +DROP TABLE IF EXISTS `#__mokosuitefield_agreements`; +DROP TABLE IF EXISTS `#__mokosuitefield_equipment`; +DROP TABLE IF EXISTS `#__mokosuitefield_locations`; +DROP TABLE IF EXISTS `#__mokosuitefield_technicians`; diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php new file mode 100644 index 0000000..487b566 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php @@ -0,0 +1,144 @@ +get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('t.*, cd.name AS tech_name, cd.telephone') + ->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.status IN (' . $db->quote('dispatched') . ',' . $db->quote('en_route') . ',' . $db->quote('on_site') . ',' . $db->quote('in_progress') . ') AND wo.scheduled_date = CURDATE()) AS today_jobs') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->where($db->quoteName('t.status') . ' = ' . $db->quote('available')) + ->where('(' . $db->quoteName('t.trade') . ' = ' . $db->quote($trade) . ' OR ' . $db->quoteName('t.trade') . ' = ' . $db->quote('multi_trade') . ')') + ->having('today_jobs < t.max_daily_jobs') + ->order('today_jobs ASC'); + + $db->setQuery($query); + $techs = $db->loadObjectList() ?: []; + + if (empty($techs)) return null; + + // If skills required, filter + if ($requiredSkills) { + $techs = array_filter($techs, function ($t) use ($requiredSkills) { + $techSkills = json_decode($t->skills ?? '[]', true) ?: []; + foreach ($requiredSkills as $skill) { + if (!in_array($skill, $techSkills)) return false; + } + return true; + }); + } + + return !empty($techs) ? reset($techs) : null; + } + + /** + * Dispatch a work order to a technician. + */ + public static function dispatch(int $workOrderId, int $technicianId): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $db->updateObject('#__mokosuitefield_work_orders', (object) [ + 'id' => $workOrderId, + 'technician_id' => $technicianId, + 'status' => 'dispatched', + 'modified' => $now, + ], 'id'); + + $db->updateObject('#__mokosuitefield_technicians', (object) [ + 'id' => $technicianId, + 'status' => 'dispatched', + ], 'id'); + + // Log dispatch + $db->insertObject('#__mokosuitefield_dispatch_log', (object) [ + 'work_order_id' => $workOrderId, + 'technician_id' => $technicianId, + 'action' => 'assigned', + 'created_by' => Factory::getApplication()->getIdentity()->id, + 'created' => $now, + ]); + + return true; + } + + /** + * Get today's dispatch board — all jobs organized by tech. + */ + public static function getDispatchBoard(string $date = ''): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $date = $date ?: date('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('t.id AS tech_id, cd.name AS tech_name, t.status AS tech_status, t.trade') + ->select('wo.id AS wo_id, wo.wo_number, wo.status AS wo_status, wo.priority, wo.category') + ->select('wo.scheduled_time_start, wo.scheduled_time_end') + ->select('loc.address, loc.city, cust.name AS customer_name') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_work_orders', 'wo') . ' ON wo.technician_id = t.id AND wo.scheduled_date = ' . $db->quote($date) . ' AND wo.status NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ')') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cust') . ' ON cust.id = wo.contact_id') + ->order('t.id ASC, wo.scheduled_time_start ASC')); + + $rows = $db->loadObjectList() ?: []; + + // Group by tech + $board = []; + foreach ($rows as $row) { + $techId = (int) $row->tech_id; + if (!isset($board[$techId])) { + $board[$techId] = (object) [ + 'tech_id' => $techId, + 'tech_name' => $row->tech_name, + 'status' => $row->tech_status, + 'trade' => $row->trade, + 'jobs' => [], + ]; + } + if ($row->wo_id) { + $board[$techId]->jobs[] = $row; + } + } + + return array_values($board); + } + + /** + * Get unassigned work orders. + */ + public static function getUnassigned(): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, loc.address, loc.city, loc.zip') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->where($db->quoteName('wo.technician_id') . ' IS NULL') + ->where($db->quoteName('wo.status') . ' = ' . $db->quote('new')) + ->order('FIELD(wo.priority, ' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ',' . $db->quote('low') . ',' . $db->quote('scheduled') . ') ASC, wo.created ASC')); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/EquipmentHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/EquipmentHelper.php new file mode 100644 index 0000000..1d56d11 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/EquipmentHelper.php @@ -0,0 +1,77 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitefield_equipment') + ->where('location_id = ' . $locationId) + ->order('equipment_type ASC, make ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function getByQrCode(string $qrCode): ?object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true)->select('e.*, loc.address, loc.city, cd.name AS owner_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'e')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->where($db->quoteName('e.qr_code') . ' = ' . $db->quote($qrCode))); + + return $db->loadObject() ?: null; + } + + public static function getDueForService(int $daysAhead = 30): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('e.*, loc.address, loc.city, cd.name AS owner_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'e')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->where($db->quoteName('e.next_service_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)') + ->order('e.next_service_date ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function getWarrantyExpiring(int $daysAhead = 90): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('e.*, loc.address, cd.name AS owner_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'e')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->where($db->quoteName('e.warranty_expiry') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)') + ->order('e.warranty_expiry ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function recordService(int $equipmentId): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->updateObject('#__mokosuitefield_equipment', (object) [ + 'id' => $equipmentId, 'last_service_date' => date('Y-m-d'), 'modified' => Factory::getDate()->toSql(), + ], 'id'); + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/EstimateHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/EstimateHelper.php new file mode 100644 index 0000000..3ef628c --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/EstimateHelper.php @@ -0,0 +1,83 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + $seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuitefield_estimates'))->loadResult() + 1; + + $estimate = (object) [ + 'estimate_number' => 'EST-' . date('Ymd') . '-' . str_pad($seq, 4, '0', STR_PAD_LEFT), + 'contact_id' => $contactId, + 'location_id' => (int) ($data['location_id'] ?? 0) ?: null, + 'work_order_id' => (int) ($data['work_order_id'] ?? 0) ?: null, + 'title' => $title, + 'description' => $data['description'] ?? '', + 'total' => (float) ($data['total'] ?? 0), + 'status' => 'draft', + 'valid_days' => (int) ($data['valid_days'] ?? 30), + 'token' => bin2hex(random_bytes(32)), + 'created_by' => Factory::getApplication()->getIdentity()->id, + 'created' => $now, + ]; + + $db->insertObject('#__mokosuitefield_estimates', $estimate, 'id'); + return (int) $estimate->id; + } + + public static function send(int $estimateId): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true) + ->select('e.*, cd.name AS customer_name, cd.email_to') + ->from($db->quoteName('#__mokosuitefield_estimates', 'e')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->where('e.id = ' . $estimateId)); + $est = $db->loadObject(); + + if (!$est || !$est->email_to) return false; + + $url = Uri::root() . 'index.php?option=com_mokosuitefield&view=estimate&token=' . $est->token; + $mailer = Factory::getMailer(); + $mailer->addRecipient($est->email_to, $est->customer_name); + $mailer->setSubject('Estimate ' . $est->estimate_number); + $mailer->setBody("Estimate ready for review:\n{$url}\n\nTotal: \$" . number_format((float) $est->total, 2)); + $result = $mailer->Send(); + + if ($result) { + $db->updateObject('#__mokosuitefield_estimates', (object) ['id' => $estimateId, 'status' => 'sent'], 'id'); + } + return (bool) $result; + } + + public static function accept(string $token, ?string $signature = null): ?int + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuitefield_estimates') + ->where($db->quoteName('token') . ' = ' . $db->quote($token)) + ->where($db->quoteName('status') . ' IN (' . $db->quote('sent') . ',' . $db->quote('viewed') . ')')); + $est = $db->loadObject(); + if (!$est) return null; + + $db->updateObject('#__mokosuitefield_estimates', (object) [ + 'id' => $est->id, 'status' => 'accepted', 'accepted_at' => Factory::getDate()->toSql(), + 'customer_signature' => $signature, + ], 'id'); + + $woId = WorkOrderHelper::create((int) $est->contact_id, 'general', $est->title, [ + 'location_id' => $est->location_id, 'source' => 'website', + ]); + + $db->updateObject('#__mokosuitefield_estimates', (object) ['id' => $est->id, 'status' => 'converted', 'converted_wo_id' => $woId], 'id'); + return $woId; + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/InvoiceHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/InvoiceHelper.php new file mode 100644 index 0000000..83e51b7 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/InvoiceHelper.php @@ -0,0 +1,151 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + $woId = (int) $woId; + + // Load work order + $db->setQuery($db->getQuery(true) + ->select('wo.*, cd.name AS customer_name') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->where('wo.id = ' . $woId)); + $wo = $db->loadObject(); + + if (!$wo) throw new \RuntimeException('Work order not found: ' . $woId); + + $db->transactionStart(); + + $params = Factory::getApplication()->getParams('com_mokosuitefield'); + $laborRate = (float) $params->get('default_labor_rate', 85.00); + + // Get time entries + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitefield_time_entries') + ->where('wo_id = ' . $woId)); + $timeEntries = $db->loadObjectList() ?: []; + + $totalHours = 0; + foreach ($timeEntries as $te) { + $totalHours += (float) $te->hours; + } + + // Get parts used + $db->setQuery($db->getQuery(true) + ->select('wi.*, p.title AS part_name, p.price') + ->from($db->quoteName('#__mokosuitefield_wo_items', 'wi')) + ->join('LEFT', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = wi.product_id') + ->where('wi.wo_id = ' . $woId)); + $parts = $db->loadObjectList() ?: []; + + $partsTotal = 0; + foreach ($parts as $part) { + $partsTotal += (float) ($part->price ?? 0) * (int) $part->quantity; + } + + $laborTotal = round($totalHours * $laborRate, 2); + $subtotal = $laborTotal + $partsTotal; + $taxRate = (float) $params->get('default_tax_rate', 0.07); + $tax = round($subtotal * $taxRate, 2); + $total = $subtotal + $tax; + + // Create CRM invoice + $seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuite_crm_invoices'))->loadResult() + 1; + + $invoice = (object) [ + 'contact_id' => $wo->contact_id, + 'invoice_number' => 'FSI-' . date('Ymd') . '-' . str_pad($seq, 4, '0', STR_PAD_LEFT), + 'type' => 'standard', + 'subtotal' => $subtotal, + 'tax' => $tax, + 'total' => $total, + 'balance_due' => $total, + 'status' => 'draft', + 'due_date' => date('Y-m-d', strtotime('+30 days')), + 'notes' => 'Field Service Invoice for WO #' . ($wo->wo_number ?? $woId), + 'created' => $now, + 'created_by' => Factory::getApplication()->getIdentity()->id, + ]; + $db->insertObject('#__mokosuite_crm_invoices', $invoice, 'id'); + $invoiceId = (int) $invoice->id; + + // Add labor line item + if ($totalHours > 0) { + $db->insertObject('#__mokosuite_crm_invoice_items', (object) [ + 'invoice_id' => $invoiceId, + 'description' => 'Labor — ' . number_format($totalHours, 1) . ' hours @ $' . number_format($laborRate, 2) . '/hr', + 'quantity' => $totalHours, + 'unit_price' => $laborRate, + 'line_total' => $laborTotal, + ]); + } + + // Add parts line items + foreach ($parts as $part) { + $db->insertObject('#__mokosuite_crm_invoice_items', (object) [ + 'invoice_id' => $invoiceId, + 'product_id' => $part->product_id, + 'description' => $part->part_name ?? 'Part', + 'quantity' => $part->quantity, + 'unit_price' => $part->price ?? 0, + 'line_total' => round((float) ($part->price ?? 0) * (int) $part->quantity, 2), + ]); + } + + // Link invoice to work order + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitefield_work_orders') + ->set('invoice_id = ' . $invoiceId) + ->where('id = ' . $woId)); + $db->execute(); + + $db->transactionCommit(); + + return $invoiceId; + } + + /** + * Batch generate invoices for all completed, uninvoiced work orders. + */ + public static function batchGenerate(): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('id') + ->from('#__mokosuitefield_work_orders') + ->where($db->quoteName('status') . ' = ' . $db->quote('completed')) + ->where('invoice_id IS NULL OR invoice_id = 0') + ->order('completed_at ASC')); + $woIds = $db->loadColumn() ?: []; + + $results = []; + foreach ($woIds as $woId) { + try { + $invoiceId = self::generateFromWorkOrder((int) $woId); + $results[] = ['wo_id' => $woId, 'invoice_id' => $invoiceId, 'success' => true]; + } catch (\Throwable $e) { + $results[] = ['wo_id' => $woId, 'error' => $e->getMessage(), 'success' => false]; + } + } + + return $results; + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php new file mode 100644 index 0000000..701348c --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php @@ -0,0 +1,239 @@ +get(DatabaseInterface::class); + $date = $date ?: date('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('wo.id, wo.wo_number, wo.priority, wo.status, wo.trade') + ->select('wo.scheduled_date, wo.scheduled_time, wo.estimated_duration') + ->select('wo.route_order') + ->select('l.name AS location_name, l.address, l.city, l.state, l.zip') + ->select('l.latitude, l.longitude') + ->select('cd.name AS customer_name, cd.telephone AS customer_phone') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'l') . ' ON l.id = wo.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->where($db->quoteName('wo.technician_id') . ' = ' . (int) $techId) + ->where($db->quoteName('wo.scheduled_date') . ' = ' . $db->quote($date)) + ->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('cancelled') . ',' . $db->quote('completed') . ')') + ->order('wo.route_order ASC, wo.scheduled_time ASC')); + + return $db->loadObjectList() ?: []; + } + + /** + * Auto-assign route order based on geographic proximity (nearest-neighbor heuristic). + * Starts from the tech's home base or first WO location. + */ + public static function optimizeRoute(int $techId, string $date = ''): array + { + $stops = self::getTechRoute($techId, $date); + if (count($stops) <= 1) return $stops; + + // Get tech home location + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true) + ->select('home_latitude, home_longitude') + ->from('#__mokosuitefield_technicians') + ->where('id = ' . (int) $techId)); + $tech = $db->loadObject(); + + $currentLat = (float) ($tech->home_latitude ?? 0); + $currentLng = (float) ($tech->home_longitude ?? 0); + + // Nearest-neighbor sort + $ordered = []; + $remaining = $stops; + + while (!empty($remaining)) { + $bestIdx = 0; + $bestDist = PHP_FLOAT_MAX; + + foreach ($remaining as $idx => $stop) { + $lat = (float) ($stop->latitude ?? 0); + $lng = (float) ($stop->longitude ?? 0); + + if ($lat === 0.0 && $lng === 0.0) { + // No coordinates — keep in original position + $dist = PHP_FLOAT_MAX - 1; + } else { + $dist = self::haversine($currentLat, $currentLng, $lat, $lng); + } + + if ($dist < $bestDist) { + $bestDist = $dist; + $bestIdx = $idx; + } + } + + $next = $remaining[$bestIdx]; + $ordered[] = $next; + $currentLat = (float) ($next->latitude ?? $currentLat); + $currentLng = (float) ($next->longitude ?? $currentLng); + array_splice($remaining, $bestIdx, 1); + $remaining = array_values($remaining); + } + + // Save route order + foreach ($ordered as $i => $stop) { + $update = (object) [ + 'id' => $stop->id, + 'route_order' => $i + 1, + ]; + $db->updateObject('#__mokosuitefield_work_orders', $update, 'id'); + $ordered[$i]->route_order = $i + 1; + } + + return $ordered; + } + + /** + * Manually reorder a stop within a tech's route. + */ + public static function reorderStop(int $woId, int $newPosition): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('technician_id, scheduled_date') + ->from('#__mokosuitefield_work_orders') + ->where('id = ' . (int) $woId)); + $wo = $db->loadObject(); + if (!$wo) return false; + + $stops = self::getTechRoute((int) $wo->technician_id, $wo->scheduled_date); + + // Find and remove the target WO + $target = null; + $filtered = []; + foreach ($stops as $stop) { + if ((int) $stop->id === $woId) { + $target = $stop; + } else { + $filtered[] = $stop; + } + } + + if (!$target) return false; + + // Insert at new position + $newPosition = max(1, min($newPosition, count($filtered) + 1)); + array_splice($filtered, $newPosition - 1, 0, [$target]); + + // Save new order + foreach ($filtered as $i => $stop) { + $update = (object) [ + 'id' => $stop->id, + 'route_order' => $i + 1, + ]; + $db->updateObject('#__mokosuitefield_work_orders', $update, 'id'); + } + + return true; + } + + /** + * Estimate total drive time and distance for a route (using straight-line approximation). + */ + public static function estimateRouteMetrics(int $techId, string $date = ''): object + { + $stops = self::getTechRoute($techId, $date); + + $totalDistance = 0.0; + $totalJobTime = 0; + $legs = []; + + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true) + ->select('home_latitude, home_longitude') + ->from('#__mokosuitefield_technicians') + ->where('id = ' . (int) $techId)); + $tech = $db->loadObject(); + + $prevLat = (float) ($tech->home_latitude ?? 0); + $prevLng = (float) ($tech->home_longitude ?? 0); + + foreach ($stops as $stop) { + $lat = (float) ($stop->latitude ?? 0); + $lng = (float) ($stop->longitude ?? 0); + $dist = 0; + + if ($lat && $lng && ($prevLat || $prevLng)) { + $dist = self::haversine($prevLat, $prevLng, $lat, $lng); + } + + $totalDistance += $dist; + $totalJobTime += (int) ($stop->estimated_duration ?? 60); + + $legs[] = (object) [ + 'wo_id' => $stop->id, + 'location' => $stop->location_name ?? $stop->address, + 'distance' => round($dist, 1), + 'drive_min'=> round($dist / self::AVG_SPEED_MPH * 60, 0), + ]; + + $prevLat = $lat ?: $prevLat; + $prevLng = $lng ?: $prevLng; + } + + $totalDriveMin = $totalDistance > 0 ? round($totalDistance / self::AVG_SPEED_MPH * 60) : 0; + + return (object) [ + 'stop_count' => count($stops), + 'total_distance' => round($totalDistance, 1), + 'total_drive_min'=> $totalDriveMin, + 'total_job_min' => $totalJobTime, + 'total_day_min' => $totalDriveMin + $totalJobTime, + 'legs' => $legs, + ]; + } + + /** + * Haversine distance in miles. + */ + private static function haversine(float $lat1, float $lon1, float $lat2, float $lon2): float + { + $R = 3959; // Earth radius in miles + $dLat = deg2rad($lat2 - $lat1); + $dLon = deg2rad($lon2 - $lon1); + $a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon / 2) ** 2; + $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); + return $R * $c; + } + + /** + * Log a GPS breadcrumb for a tech (called from mobile app). + */ + public static function logGpsBreadcrumb(int $techId, float $lat, float $lng, ?int $woId = null): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $crumb = (object) [ + 'technician_id' => $techId, + 'latitude' => $lat, + 'longitude' => $lng, + 'wo_id' => $woId, + 'recorded_at' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitefield_dispatch_log', $crumb); + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/ServiceAgreementHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/ServiceAgreementHelper.php new file mode 100644 index 0000000..ab2f919 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/ServiceAgreementHelper.php @@ -0,0 +1,78 @@ +get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('a.*, cd.name AS customer_name, loc.address') + ->from($db->quoteName('#__mokosuitefield_agreements', 'a')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = a.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = a.location_id') + ->where($db->quoteName('a.status') . ' = ' . $db->quote('active')) + ->order('a.end_date ASC'); + + if ($contactId) $query->where('a.contact_id = ' . $contactId); + + $db->setQuery($query); + $agreements = $db->loadObjectList() ?: []; + + foreach ($agreements as &$a) { + $a->visits_remaining = max(0, (int) $a->visits_per_year - (int) $a->visits_used); + $a->days_until_expiry = $a->end_date ? max(0, round((strtotime($a->end_date) - time()) / 86400)) : null; + } + + return $agreements; + } + + public static function getExpiring(int $daysAhead = 30): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('a.*, cd.name AS customer_name, cd.email_to') + ->from($db->quoteName('#__mokosuitefield_agreements', 'a')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = a.contact_id') + ->where($db->quoteName('a.status') . ' = ' . $db->quote('active')) + ->where($db->quoteName('a.end_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)') + ->order('a.end_date ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function getRevenueSummary(): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS active_agreements') + ->select('COALESCE(SUM(annual_amount), 0) AS annual_recurring') + ->select('COALESCE(SUM(annual_amount / 12), 0) AS monthly_recurring') + ->from('#__mokosuitefield_agreements') + ->where($db->quoteName('status') . ' = ' . $db->quote('active'))); + + return $db->loadObject() ?: (object) ['active_agreements' => 0, 'annual_recurring' => 0, 'monthly_recurring' => 0]; + } + + public static function recordVisit(int $agreementId, int $workOrderId): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitefield_agreements') + ->set('visits_used = visits_used + 1') + ->where('id = ' . $agreementId)); + $db->execute(); + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/TruckStockHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/TruckStockHelper.php new file mode 100644 index 0000000..fbd7aa6 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/TruckStockHelper.php @@ -0,0 +1,64 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('ts.*, p.name AS part_name, p.sku, p.cost_price') + ->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts')) + ->join('INNER', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id') + ->where('ts.vehicle_id = ' . $vehicleId) + ->order('p.name ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function getLowStock(): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('ts.*, p.name AS part_name, p.sku, v.vehicle_number') + ->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts')) + ->join('INNER', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id') + ->join('INNER', $db->quoteName('#__mokosuitefield_vehicles', 'v') . ' ON v.id = ts.vehicle_id') + ->where('ts.quantity <= ts.min_quantity') + ->order('ts.quantity ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function usePart(int $vehicleId, int $productId, float $qty = 1): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitefield_truck_stock') + ->set('quantity = quantity - ' . (float) $qty) + ->where('vehicle_id = ' . $vehicleId) + ->where('product_id = ' . $productId)); + $db->execute(); + + return $db->getAffectedRows() > 0; + } + + public static function restock(int $vehicleId, int $productId, float $qty): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery("INSERT INTO #__mokosuitefield_truck_stock (vehicle_id, product_id, quantity, last_restocked) VALUES ({$vehicleId}, {$productId}, {$qty}, CURDATE()) ON DUPLICATE KEY UPDATE quantity = quantity + {$qty}, last_restocked = CURDATE()"); + $db->execute(); + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/VehicleHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/VehicleHelper.php new file mode 100644 index 0000000..0d32603 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/VehicleHelper.php @@ -0,0 +1,44 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('v.*, cd.name AS assigned_tech_name') + ->select('(SELECT COUNT(*) FROM #__mokosuitefield_truck_stock ts WHERE ts.vehicle_id = v.id AND ts.quantity <= ts.min_quantity) AS low_stock_items') + ->from($db->quoteName('#__mokosuitefield_vehicles', 'v')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.assigned_tech_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->order('v.vehicle_number ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function getInspectionsDue(int $daysAhead = 30): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('v.*, cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_vehicles', 'v')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.assigned_tech_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->where($db->quoteName('v.status') . ' = ' . $db->quote('active')) + ->where($db->quoteName('v.next_inspection') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)') + ->order('v.next_inspection ASC')); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php new file mode 100644 index 0000000..e6270e2 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php @@ -0,0 +1,162 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + // Read prefix from component config (config.xml) + $params = Factory::getApplication()->getParams('com_mokosuitefield'); + $woPrefix = $params->get('wo_prefix', 'WO'); + $defaultTrade = $params->get('default_trade', 'general'); + + if (!$trade) $trade = $defaultTrade; + + $seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuitefield_work_orders'))->loadResult() + 1; + + $wo = (object) [ + 'wo_number' => $woPrefix . '-' . date('Ymd') . '-' . str_pad($seq, 4, '0', STR_PAD_LEFT), + 'contact_id' => $contactId, + 'location_id' => (int) ($data['location_id'] ?? 0) ?: null, + 'trade' => $trade, + 'priority' => $data['priority'] ?? 'normal', + 'status' => 'new', + 'category' => $data['category'] ?? null, + 'description' => $description, + 'customer_po' => $data['customer_po'] ?? null, + 'scheduled_date' => $data['scheduled_date'] ?? null, + 'scheduled_time_start' => $data['time_start'] ?? null, + 'scheduled_time_end' => $data['time_end'] ?? null, + 'service_agreement_id' => (int) ($data['agreement_id'] ?? 0) ?: null, + 'source' => $data['source'] ?? 'phone', + 'created_by' => Factory::getApplication()->getIdentity()->id, + 'created' => $now, + ]; + + $db->insertObject('#__mokosuitefield_work_orders', $wo, 'id'); + + return (int) $wo->id; + } + + public static function updateStatus(int $woId, string $status, ?float $lat = null, ?float $lng = null): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $update = (object) ['id' => $woId, 'status' => $status, 'modified' => $now]; + + if ($status === 'on_site') $update->actual_arrival = $now; + if ($status === 'completed') $update->actual_departure = $now; + + $db->updateObject('#__mokosuitefield_work_orders', $update, 'id'); + + // Get tech ID for dispatch log + $db->setQuery($db->getQuery(true)->select('technician_id')->from('#__mokosuitefield_work_orders')->where('id = ' . $woId)); + $techId = (int) $db->loadResult(); + + if ($techId) { + $action = match ($status) { + 'en_route' => 'en_route', + 'on_site' => 'arrived', + 'completed' => 'completed', + 'cancelled' => 'cancelled', + default => null, + }; + + if ($action) { + $db->insertObject('#__mokosuitefield_dispatch_log', (object) [ + 'work_order_id' => $woId, + 'technician_id' => $techId, + 'action' => $action, + 'latitude' => $lat, + 'longitude' => $lng, + 'created_by' => Factory::getApplication()->getIdentity()->id, + 'created' => $now, + ]); + } + + // Update tech status + if ($status === 'completed' || $status === 'cancelled') { + $db->updateObject('#__mokosuitefield_technicians', (object) ['id' => $techId, 'status' => 'available'], 'id'); + } elseif ($status === 'en_route') { + $db->updateObject('#__mokosuitefield_technicians', (object) [ + 'id' => $techId, 'status' => 'en_route', 'current_lat' => $lat, 'current_lng' => $lng, 'last_location_update' => $now, + ], 'id'); + } elseif ($status === 'on_site') { + $db->updateObject('#__mokosuitefield_technicians', (object) ['id' => $techId, 'status' => 'on_site'], 'id'); + } + } + } + + public static function complete(int $woId, string $workPerformed, ?string $signature = null): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + // Calculate totals from line items + $db->setQuery($db->getQuery(true) + ->select('COALESCE(SUM(CASE WHEN item_type = ' . $db->quote('labor') . ' THEN line_total ELSE 0 END), 0) AS labor') + ->select('COALESCE(SUM(CASE WHEN item_type != ' . $db->quote('labor') . ' THEN line_total ELSE 0 END), 0) AS parts') + ->from('#__mokosuitefield_wo_items') + ->where('work_order_id = ' . $woId)); + $totals = $db->loadObject(); + + $laborTotal = (float) ($totals->labor ?? 0); + $partsTotal = (float) ($totals->parts ?? 0); + $total = $laborTotal + $partsTotal; + + $db->updateObject('#__mokosuitefield_work_orders', (object) [ + 'id' => $woId, + 'status' => 'completed', + 'work_performed' => $workPerformed, + 'parts_total' => $partsTotal, + 'labor_total' => $laborTotal, + 'total' => $total, + 'customer_signature' => $signature, + 'customer_signed_at' => $signature ? $now : null, + 'actual_departure' => $now, + 'modified' => $now, + ], 'id'); + + // Update location service history + $db->setQuery($db->getQuery(true)->select('location_id')->from('#__mokosuitefield_work_orders')->where('id = ' . $woId)); + $locId = (int) $db->loadResult(); + if ($locId) { + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitefield_locations') + ->set('service_history_count = service_history_count + 1') + ->set('last_service_date = ' . $db->quote(date('Y-m-d'))) + ->where('id = ' . $locId)); + $db->execute(); + } + } + + public static function getDashboardStats(): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $today = date('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total_today') + ->select('SUM(CASE WHEN status = ' . $db->quote('new') . ' THEN 1 ELSE 0 END) AS unassigned') + ->select('SUM(CASE WHEN status IN (' . $db->quote('dispatched') . ',' . $db->quote('en_route') . ') THEN 1 ELSE 0 END) AS en_route') + ->select('SUM(CASE WHEN status IN (' . $db->quote('on_site') . ',' . $db->quote('in_progress') . ') THEN 1 ELSE 0 END) AS on_site') + ->select('SUM(CASE WHEN status = ' . $db->quote('completed') . ' THEN 1 ELSE 0 END) AS completed') + ->select('SUM(CASE WHEN priority IN (' . $db->quote('emergency') . ',' . $db->quote('urgent') . ') THEN 1 ELSE 0 END) AS urgent') + ->from('#__mokosuitefield_work_orders') + ->where('scheduled_date = ' . $db->quote($today) . ' OR (scheduled_date IS NULL AND DATE(created) = ' . $db->quote($today) . ')')); + + return $db->loadObject() ?: (object) ['total_today' => 0, 'unassigned' => 0, 'en_route' => 0, 'on_site' => 0, 'completed' => 0, 'urgent' => 0]; + } +} diff --git a/source/packages/plg_task_mokosuitefield/src/Extension/FieldAutomation.php b/source/packages/plg_task_mokosuitefield/src/Extension/FieldAutomation.php new file mode 100644 index 0000000..9fbdc96 --- /dev/null +++ b/source/packages/plg_task_mokosuitefield/src/Extension/FieldAutomation.php @@ -0,0 +1,144 @@ + [ + 'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_SERVICE_REMINDERS', + 'method' => 'sendServiceReminders', + ], + 'mokosuite.field.agreement.renewal' => [ + 'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_AGREEMENT_RENEWAL', + 'method' => 'checkAgreementRenewals', + ], + 'mokosuite.field.equipment.maintenance' => [ + 'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_EQUIPMENT_MAINTENANCE', + 'method' => 'checkEquipmentMaintenance', + ], + 'mokosuite.field.truck.reorder' => [ + 'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_TRUCK_REORDER', + 'method' => 'checkTruckStock', + ], + ]; + + public static function getSubscribedEvents(): array + { + return [ + 'onExecuteTask' => 'standardRoutineHandler', + 'onContentPrepareForm' => 'enhanceTaskItemForm', + 'onTaskOptionsList' => 'advertiseRoutines', + ]; + } + + private function sendServiceReminders(ExecuteTaskEvent $event): int + { + $equipment = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getDueForService(14); + + if (empty($equipment)) return Status::OK; + + $body = "Equipment due for service (next 14 days):\n\n"; + foreach ($equipment as $e) { + $body .= "- {$e->equipment_type} ({$e->make} {$e->model}) at {$e->address}, {$e->owner_name}\n"; + $body .= " Due: " . date('M j, Y', strtotime($e->next_service_date)) . "\n"; + } + + $mailer = Factory::getMailer(); + $mailer->addRecipient(Factory::getApplication()->get('mailfrom')); + $mailer->setSubject('Field Service: ' . count($equipment) . ' equipment due for maintenance'); + $mailer->setBody($body); + $mailer->Send(); + + Log::add("Field equipment reminders: " . count($equipment) . " due", Log::INFO, 'mokosuite.field'); + return Status::OK; + } + + private function checkAgreementRenewals(ExecuteTaskEvent $event): int + { + $expiring = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getExpiring(30); + + foreach ($expiring as $a) { + if (!$a->email_to) continue; + + $mailer = Factory::getMailer(); + $mailer->addRecipient($a->email_to, $a->customer_name); + $mailer->setSubject('Service Agreement Renewal — ' . $a->title); + $mailer->setBody( + "Hi {$a->customer_name},\n\n" + . "Your service agreement \"{$a->title}\" expires on " . date('F j, Y', strtotime($a->end_date)) . ".\n\n" + . "Please contact us to renew.\n" + ); + $mailer->Send(); + } + + Log::add("Field agreement renewals: " . count($expiring) . " expiring", Log::INFO, 'mokosuite.field'); + return Status::OK; + } + + private function checkEquipmentMaintenance(ExecuteTaskEvent $event): int + { + $warranty = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getWarrantyExpiring(90); + + if (!empty($warranty)) { + $body = "Equipment warranties expiring (90 days):\n\n"; + foreach ($warranty as $e) { + $body .= "- {$e->make} {$e->model} (SN: {$e->serial_number}) — {$e->owner_name}\n"; + $body .= " Warranty expires: " . date('M j, Y', strtotime($e->warranty_expiry)) . "\n"; + } + + $mailer = Factory::getMailer(); + $mailer->addRecipient(Factory::getApplication()->get('mailfrom')); + $mailer->setSubject('Field: ' . count($warranty) . ' equipment warranties expiring'); + $mailer->setBody($body); + $mailer->Send(); + } + + return Status::OK; + } + + private function checkTruckStock(ExecuteTaskEvent $event): int + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('ts.*, p.name AS part_name, p.sku, v.vehicle_number') + ->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts')) + ->join('INNER', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id') + ->join('INNER', $db->quoteName('#__mokosuitefield_vehicles', 'v') . ' ON v.id = ts.vehicle_id') + ->where('ts.quantity <= ts.min_quantity')); + $low = $db->loadObjectList() ?: []; + + if (!empty($low)) { + $body = "Truck stock below reorder point:\n\n"; + foreach ($low as $item) { + $body .= "- Vehicle {$item->vehicle_number}: {$item->part_name} ({$item->sku}) — {$item->quantity} remaining (min: {$item->min_quantity})\n"; + } + + $mailer = Factory::getMailer(); + $mailer->addRecipient(Factory::getApplication()->get('mailfrom')); + $mailer->setSubject('Field: ' . count($low) . ' truck stock items need reorder'); + $mailer->setBody($body); + $mailer->Send(); + } + + Log::add("Field truck stock: " . count($low) . " items low", Log::INFO, 'mokosuite.field'); + return Status::OK; + } +} diff --git a/source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteFieldApi.php b/source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteFieldApi.php new file mode 100644 index 0000000..4dfb1fa --- /dev/null +++ b/source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteFieldApi.php @@ -0,0 +1,27 @@ + 'onBeforeApiRoute']; + } + + public function onBeforeApiRoute(BeforeApiRouteEvent $event): void + { + $router = $event->getRouter(); + $router->createCRUDRoutes('v1/mokosuite/field/workorders', 'fieldworkorders', ['component' => 'com_mokosuitefield']); + $router->createCRUDRoutes('v1/mokosuite/field/technicians', 'fieldtechnicians', ['component' => 'com_mokosuitefield']); + $router->createCRUDRoutes('v1/mokosuite/field/equipment', 'fieldequipment', ['component' => 'com_mokosuitefield']); + $router->createCRUDRoutes('v1/mokosuite/field/agreements', 'fieldagreements', ['component' => 'com_mokosuitefield']); + $router->createCRUDRoutes('v1/mokosuite/field/estimates', 'fieldestimates', ['component' => 'com_mokosuitefield']); + $router->createCRUDRoutes('v1/mokosuite/field/locations', 'fieldlocations', ['component' => 'com_mokosuitefield']); + } +} diff --git a/source/pkg_mokosuitefield.xml b/source/pkg_mokosuitefield.xml new file mode 100644 index 0000000..849de34 --- /dev/null +++ b/source/pkg_mokosuitefield.xml @@ -0,0 +1,24 @@ + + + Package - MokoSuite Field + mokosuitefield + 01.01.00 + 2026-06-12 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GNU General Public License version 3 or later; see LICENSE + MokoSuite Field Service - dispatch, work orders, scheduling, mobile tech. Layer 2 add-on for MokoSuite (requires CRM). + 8.3 + + true + + plg_system_mokosuitefield.zip + com_mokosuitefield.zip + plg_webservices_mokosuitefield.zip + + + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteField/updates.xml + + diff --git a/source/updates.xml b/source/updates.xml new file mode 100644 index 0000000..39bccee --- /dev/null +++ b/source/updates.xml @@ -0,0 +1,9 @@ + + +Package - MokoSuite Field +pkg_mokosuitefield +package +01.01.00 + +8.3 +