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:
@@ -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
|
||||
Submodule
+1
Submodule packages/MokoSuite added at 6cd16d9845
Submodule
+1
Submodule packages/MokoSuiteCRM added at 6c78af16e6
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user