feat: initial scaffold — MokoSuiteField service management

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
This commit is contained in:
Jonathan Miller
2026-06-13 06:23:59 -05:00
parent f5c15e596d
commit b05234ef1a
14 changed files with 786 additions and 0 deletions
+6
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
GPL-3.0-or-later
Submodule packages/MokoSuite added at 6cd16d9845
@@ -0,0 +1,18 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\Extension\MVCComponent;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(ComponentInterface::class, function (Container $container) {
$component = new MVCComponent($container->get(ComponentDispatcherFactoryInterface::class));
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
return $component;
});
}
};
@@ -0,0 +1,11 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
class DisplayController extends BaseController
{
protected $default_view = 'dashboard';
}
@@ -0,0 +1,40 @@
<?php
namespace Moko\Component\MokoSuiteField\Administrator\View\Dashboard;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Database\DatabaseInterface;
class HtmlView extends BaseHtmlView
{
public object $stats;
public array $dispatchBoard = [];
public array $unassigned = [];
public array $urgent = [];
public function display($tpl = null): void
{
$this->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);
}
}
@@ -0,0 +1,33 @@
<?php
defined('_JEXEC') or die;
$s = $this->stats;
$board = $this->dispatchBoard;
$unassigned = $this->unassigned;
$urgent = $this->urgent;
?>
<div class="row g-3 mb-4">
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int) $s->total_today; ?></div><small>Today</small></div></div></div>
<div class="col-md-2"><div class="card shadow-sm border-danger"><div class="card-body text-center"><div class="fs-3 fw-bold text-danger"><?php echo (int) $s->urgent; ?></div><small>Urgent</small></div></div></div>
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-secondary"><?php echo (int) $s->unassigned; ?></div><small>Unassigned</small></div></div></div>
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-warning"><?php echo (int) $s->en_route; ?></div><small>En Route</small></div></div></div>
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-primary"><?php echo (int) $s->on_site; ?></div><small>On Site</small></div></div></div>
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-success"><?php echo (int) $s->completed; ?></div><small>Done</small></div></div></div>
</div>
<div class="row g-3">
<div class="col-lg-8"><div class="card shadow-sm"><div class="card-header"><h5 class="mb-0">Dispatch Board</h5></div><div class="card-body">
<?php foreach ($board as $tech) : ?>
<div class="mb-3 p-2 border rounded">
<div class="d-flex justify-content-between mb-1"><strong><?php echo $this->escape($tech->tech_name); ?></strong><span class="badge bg-secondary"><?php echo ucfirst($tech->trade); ?></span></div>
<?php if (!empty($tech->jobs)) : foreach ($tech->jobs as $job) : ?>
<div class="ms-3 small border-start ps-2 mb-1"><code><?php echo $this->escape($job->wo_number); ?></code> <?php echo $this->escape($job->customer_name ?? ''); ?> <span class="text-muted"><?php echo $this->escape($job->city ?? ''); ?></span></div>
<?php endforeach; else : ?><div class="ms-3 small text-muted">No jobs</div><?php endif; ?>
</div>
<?php endforeach; ?>
</div></div></div>
<div class="col-lg-4"><div class="card shadow-sm"><div class="card-header"><h5 class="mb-0">Unassigned (<?php echo count($unassigned); ?>)</h5></div><div class="card-body p-0">
<?php foreach ($unassigned as $u) : ?>
<div class="p-2 border-bottom"><strong class="small"><?php echo $this->escape($u->customer_name ?? ''); ?></strong><br><small class="text-muted"><?php echo $this->escape($u->category ?? $u->trade); ?></small></div>
<?php endforeach; ?>
<?php if (empty($unassigned)) : ?><div class="p-3 text-muted text-center">All assigned</div><?php endif; ?>
</div></div></div>
</div>
@@ -0,0 +1,8 @@
<?php
namespace Moko\Component\MokoSuiteField\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
class DisplayController extends BaseController
{
protected $default_view = 'bookservice';
}
@@ -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;
@@ -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`;
@@ -0,0 +1,144 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Dispatch helper — assign techs to jobs based on location, skills, availability.
*/
class DispatchHelper
{
/**
* Find the best available technician for a work order.
* Considers: trade match, distance, current workload, skills.
*/
public static function findBestTech(string $trade, string $zip, ?array $requiredSkills = null): ?object
{
$db = Factory::getContainer()->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() ?: [];
}
}
@@ -0,0 +1,155 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Work order lifecycle helper — create, update status, complete, invoice.
*/
class WorkOrderHelper
{
public static function create(int $contactId, string $trade, string $description, array $data = []): int
{
$db = Factory::getContainer()->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];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="package" method="upgrade">
<name>Package - MokoSuite Field</name>
<packagename>mokosuitefield</packagename>
<version>01.01.00</version>
<creationDate>2026-06-12</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license>
<description>MokoSuite Field Service - dispatch, work orders, scheduling, mobile tech. Layer 2 add-on for MokoSuite (requires CRM).</description>
<php_minimum>8.3</php_minimum>
<dlid prefix="dlid=" suffix=""/>
<blockChildUninstall>true</blockChildUninstall>
<files folder="packages">
<file type="plugin" id="plg_system_mokosuitefield" group="system">plg_system_mokosuitefield.zip</file>
<file type="component" id="com_mokosuitefield">com_mokosuitefield.zip</file>
<file type="plugin" id="plg_webservices_mokosuitefield" group="webservices">plg_webservices_mokosuitefield.zip</file>
</files>
<updateservers>
<server type="extension" priority="1" name="Package - MokoSuite Field">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteField/updates.xml</server>
</updateservers>
</extension>