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;
+?>
+
+
+
+
+
+
escape($tech->tech_name); ?>trade); ?>
+jobs)) : foreach ($tech->jobs as $job) : ?>
+
escape($job->wo_number); ?> escape($job->customer_name ?? ''); ?> escape($job->city ?? ''); ?>
+
No jobs
+
+
+
+
+
+
escape($u->customer_name ?? ''); ?>
escape($u->category ?? $u->trade); ?>
+
+
All assigned
+
+
diff --git a/source/packages/com_mokosuitefield/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
+
+