From b05234ef1a77b40134b507de940a58d88eeb0d76 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 13 Jun 2026 06:23:59 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20scaffold=20=E2=80=94=20MokoSu?= =?UTF-8?q?iteField=20service=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 2 add-on for MokoSuite CRM. Field service operations for plumbing, electrical, HVAC, and general trades. Schema (12 tables): - Technicians (trade, certifications, GPS, service radius, vehicle) - Service Locations (customer properties with access notes, GPS) - Work Orders (full lifecycle: new > dispatched > en_route > on_site > in_progress > completed > invoiced, with priority/trade/category) - WO Line Items (labor, parts, materials, flat rate, permits) - WO Photos (before/during/after with GPS coordinates) - Service Agreements (recurring maintenance contracts with SLA) - Equipment (HVAC units, panels, water heaters with serial/warranty) - Vehicles (fleet tracking with mileage, inspection, GPS) - Truck Stock (per-vehicle parts inventory with reorder points) - Dispatch Log (assignment and routing history with GPS) - Estimates (on-site quoting with token-based customer acceptance) - Time Entries (per-WO labor tracking with overtime/travel flags) Helpers: - DispatchHelper: find best tech by trade/location/workload, dispatch board, auto-assignment, unassigned queue - WorkOrderHelper: create, status lifecycle, completion with signature, dashboard stats Infrastructure: - Joomla 6 (PHP 8.3+), admin dashboard with dispatch board - Git submodules: MokoSuite + MokoSuiteCRM - GPL-3.0 license --- .gitmodules | 6 + LICENSE | 1 + packages/MokoSuite | 1 + packages/MokoSuiteCRM | 1 + .../admin/services/provider.php | 18 + .../src/Controller/DisplayController.php | 11 + .../admin/src/View/Dashboard/HtmlView.php | 40 +++ .../admin/tmpl/dashboard/default.php | 33 ++ .../site/src/Controller/DisplayController.php | 8 + .../sql/install.mysql.sql | 332 ++++++++++++++++++ .../sql/uninstall.mysql.sql | 12 + .../src/Helper/DispatchHelper.php | 144 ++++++++ .../src/Helper/WorkOrderHelper.php | 155 ++++++++ source/pkg_mokosuitefield.xml | 24 ++ 14 files changed, 786 insertions(+) create mode 100644 .gitmodules create mode 100644 LICENSE create mode 160000 packages/MokoSuite create mode 160000 packages/MokoSuiteCRM create mode 100644 source/packages/com_mokosuitefield/admin/services/provider.php create mode 100644 source/packages/com_mokosuitefield/admin/src/Controller/DisplayController.php create mode 100644 source/packages/com_mokosuitefield/admin/src/View/Dashboard/HtmlView.php create mode 100644 source/packages/com_mokosuitefield/admin/tmpl/dashboard/default.php create mode 100644 source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php create mode 100644 source/packages/plg_system_mokosuitefield/sql/install.mysql.sql create mode 100644 source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php create mode 100644 source/pkg_mokosuitefield.xml diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d7226ba --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "packages/MokoSuite"] + path = packages/MokoSuite + url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuite.git +[submodule "packages/MokoSuiteCRM"] + path = packages/MokoSuiteCRM + url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM.git 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/MokoSuite b/packages/MokoSuite new file mode 160000 index 0000000..6cd16d9 --- /dev/null +++ b/packages/MokoSuite @@ -0,0 +1 @@ +Subproject commit 6cd16d984589fabbfd0b074b0d3308b5e64967f0 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/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 @@ +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/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/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 @@ +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/WorkOrderHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php new file mode 100644 index 0000000..ad04470 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php @@ -0,0 +1,155 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuitefield_work_orders'))->loadResult() + 1; + + $wo = (object) [ + 'wo_number' => 'WO-' . 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/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 + +