Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a8570f7a3 | |||
| 756e8b664b | |||
| a8224bce93 | |||
| 711b89ea03 | |||
| 98d7ab3bb3 | |||
| ee4746e8ff | |||
| 6fa3cbbaea | |||
| 0e2433cf5c | |||
| 59f50867c0 | |||
| 941d49e0ce | |||
| ad7af89228 | |||
| 078caa423b | |||
| 1583e98cea | |||
| a737ac9106 | |||
| d03269c1ca | |||
| 29079c10e2 | |||
| b433865a6d | |||
| b3cba3ea78 | |||
| 3e79014b97 | |||
| 62d213027f | |||
| b2d1e4ba23 | |||
| d4c4d1be2d |
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 02.46.80
|
||||
# VERSION: 02.46.95
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
INGROUP: MokoSuiteClient.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
PATH: ./CHANGELOG.md
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
BRIEF: Version history using `Keep a Changelog`
|
||||
-->
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
PATH: ./CODE_OF_CONDUCT.md
|
||||
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
||||
-->
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
DEFGROUP: mokoconsulting-tech.MokoSuiteClientBrand
|
||||
INGROUP: MokoStandards.Governance
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClientBrand
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
PATH: /GOVERNANCE.md
|
||||
BRIEF: Project governance rules, roles, and decision process for MokoSuiteClientBrand
|
||||
-->
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
INGROUP: MokoSuiteClient.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
PATH: ./LICENSE.md
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
BRIEF: Project license (GPL-3.0-or-later)
|
||||
-->
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
PATH: /README.md
|
||||
BRIEF: MokoSuiteClient platform plugin for Joomla
|
||||
-->
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
|
||||
INGROUP: [PROJECT_NAME].Documentation
|
||||
REPO: [REPOSITORY_URL]
|
||||
PATH: /SECURITY.md
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
INGROUP: MokoSuiteClient.Build
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
FILE: build-guide.md
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
PATH: /docs/guides/
|
||||
BRIEF: Build and packaging guide for the MokoSuiteClient system plugin
|
||||
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Build Guide (VERSION: 02.46.80)
|
||||
# MokoSuiteClient Build Guide (VERSION: 02.46.95)
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
PATH: /docs/guides/configuration-guide.md
|
||||
BRIEF: Configuration guide for the MokoSuiteClient system plugin
|
||||
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Configuration Guide (VERSION: 02.46.80)
|
||||
# MokoSuiteClient Configuration Guide (VERSION: 02.46.95)
|
||||
|
||||
## 1. Objective
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
PATH: /docs/guides/installation-guide.md
|
||||
BRIEF: Installation guide for the MokoSuiteClient system plugin
|
||||
NOTE: First document in the guide set
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Installation Guide (VERSION: 02.46.80)
|
||||
# MokoSuiteClient Installation Guide (VERSION: 02.46.95)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
PATH: /docs/guides/operations-guide.md
|
||||
BRIEF: Operational guide for administering and managing the MokoSuiteClient system plugin
|
||||
NOTE: Defines lifecycle, responsibilities, and operational behaviors
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Operations Guide (VERSION: 02.46.80)
|
||||
# MokoSuiteClient Operations Guide (VERSION: 02.46.95)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
PATH: /docs/guides/rollback-and-recovery-guide.md
|
||||
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
|
||||
NOTE: Completes the core guide set for Suite plugin governance
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.46.80)
|
||||
# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.46.95)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
PATH: /docs/guides/testing-guide.md
|
||||
BRIEF: Testing guide for MokoSuiteClient v02.01.08
|
||||
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Testing Guide (VERSION: 02.46.80)
|
||||
# MokoSuiteClient Testing Guide (VERSION: 02.46.95)
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
PATH: /docs/guides/troubleshooting-guide.md
|
||||
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuiteClient plugin
|
||||
NOTE: Designed for administrators and Suite operations teams
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Troubleshooting Guide (VERSION: 02.46.80)
|
||||
# MokoSuiteClient Troubleshooting Guide (VERSION: 02.46.95)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
PATH: /docs/guides/upgrade-and-versioning-guide.md
|
||||
BRIEF: Guide for updating, versioning, and maintaining the MokoSuiteClient plugin
|
||||
NOTE: Defines release flow, version rules, and upgrade validation
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.46.80)
|
||||
# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.46.95)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
+2
-2
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
PATH: /docs/index.md
|
||||
BRIEF: Master index of all documentation for the MokoSuiteClient plugin
|
||||
NOTE: Automatically maintained index for all guide canvases
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Documentation Index (VERSION: 02.46.80)
|
||||
# MokoSuiteClient Documentation Index (VERSION: 02.46.95)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
INGROUP: MokoSuiteClient
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
PATH: /docs/plugin-basic.md
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
BRIEF: Baseline documentation for the MokoSuiteClient system plugin
|
||||
NOTE: Foundational reference for internal and external stakeholders
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Plugin Overview (VERSION: 02.46.80)
|
||||
# MokoSuiteClient Plugin Overview (VERSION: 02.46.95)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ DEFGROUP: MokoSuiteClient.Documentation
|
||||
INGROUP: MokoStandards.Templates
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClient
|
||||
PATH: /docs/update-server.md
|
||||
VERSION: 02.46.80
|
||||
VERSION: 02.46.95
|
||||
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
||||
-->
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage com_mokosuiteclient
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Administrator\View\Ticket;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $ticket;
|
||||
protected $cannedResponses = [];
|
||||
protected $statuses = [];
|
||||
protected $priorities = [];
|
||||
protected $customFields = [];
|
||||
protected $fieldValues = [];
|
||||
protected $attachments = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->getModel('Tickets');
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
|
||||
$this->ticket = $model->getTicket($id);
|
||||
$this->cannedResponses = $model->getCannedResponses((int) ($this->ticket->category_id ?? 0));
|
||||
$this->statuses = $model->getStatuses();
|
||||
$this->priorities = $model->getPriorities();
|
||||
|
||||
// Load custom fields for this ticket's category
|
||||
if ($this->ticket && $this->ticket->category_id)
|
||||
{
|
||||
$groups = $model->getFieldGroupsForCategory((int) $this->ticket->category_id);
|
||||
$groupIds = array_column($groups, 'id');
|
||||
$this->customFields = $model->getFieldsForGroups($groupIds);
|
||||
$this->fieldValues = $model->getFieldValues($id);
|
||||
}
|
||||
|
||||
// Load attachments
|
||||
$this->attachments = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getForTicket($id);
|
||||
|
||||
if (!$this->ticket)
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
|
||||
Factory::getApplication()->redirect('index.php?option=com_mokosuiteclient&view=tickets');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
$title = $this->ticket ? 'Ticket #' . $this->ticket->id . ' — ' . $this->ticket->subject : 'Ticket';
|
||||
ToolbarHelper::title($title, 'headphones');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage com_mokosuiteclient
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Administrator\View\Tickets;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $tickets = [];
|
||||
protected $categories = [];
|
||||
protected $statusCounts;
|
||||
protected $overdue = [];
|
||||
protected $atsAvailable = null;
|
||||
protected $contacts = [];
|
||||
protected $statuses = [];
|
||||
protected $priorities = [];
|
||||
protected $backendUsers = [];
|
||||
protected $userGroups = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$app = Factory::getApplication();
|
||||
|
||||
$filters = [
|
||||
'status_id' => $app->getInput()->getInt('filter_status', 0),
|
||||
'priority_id' => $app->getInput()->getInt('filter_priority', 0),
|
||||
'category_id' => $app->getInput()->getInt('filter_category', 0),
|
||||
'contact_id' => $app->getInput()->getInt('filter_contact', 0),
|
||||
];
|
||||
|
||||
$this->tickets = $model->getTickets($filters);
|
||||
$this->categories = $model->getCategories();
|
||||
$this->statuses = $model->getStatuses();
|
||||
$this->priorities = $model->getPriorities();
|
||||
$this->statusCounts = $model->getStatusCounts();
|
||||
$this->overdue = $model->getOverdueTickets();
|
||||
$this->atsAvailable = $model->checkAtsAvailable();
|
||||
$this->contacts = $model->getContacts();
|
||||
$this->backendUsers = $model->getBackendUsers();
|
||||
$this->userGroups = $model->getUserGroups();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_TICKETS_TITLE'), 'headphones');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
||||
*
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage Component
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Administrator\View\Ticketsettings;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $statuses = [];
|
||||
protected $priorities = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->getModel('Tickets');
|
||||
|
||||
$this->statuses = $model->getStatuses();
|
||||
$this->priorities = $model->getPriorities();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_TICKET_SETTINGS'), 'cog');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1,365 +0,0 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$t = $this->ticket;
|
||||
$canned = $this->cannedResponses;
|
||||
$token = Session::getFormToken();
|
||||
$attachments = $this->attachments;
|
||||
$downloadUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.downloadAttachment');
|
||||
$uploadUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.uploadAttachment&format=json');
|
||||
$deleteAttUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteAttachment&format=json');
|
||||
|
||||
// Group attachments by reply_id (null = ticket-level)
|
||||
$attByReply = [];
|
||||
foreach ($attachments as $att) {
|
||||
$key = $att->reply_id ?? 0;
|
||||
$attByReply[$key][] = $att;
|
||||
}
|
||||
|
||||
$statuses = $this->statuses ?? [];
|
||||
$priorities = $this->priorities ?? [];
|
||||
?>
|
||||
|
||||
<div id="mokosuiteclient-ticket" class="row">
|
||||
<!-- Left: conversation thread -->
|
||||
<div class="col-12 col-xl-8">
|
||||
<!-- Original ticket -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong><?php echo $this->escape($t->created_by_name); ?></strong>
|
||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, Text::_('DATE_FORMAT_LC2')); ?></small>
|
||||
</div>
|
||||
<span class="badge bg-dark">Original</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php echo nl2br($this->escape($t->body)); ?>
|
||||
<?php if (!empty($attByReply[0])): ?>
|
||||
<hr>
|
||||
<div class="small">
|
||||
<strong>Attachments:</strong>
|
||||
<?php foreach ($attByReply[0] as $att): ?>
|
||||
<a href="<?php echo $downloadUrl . '&id=' . $att->id; ?>" class="d-inline-block me-3">
|
||||
<span class="icon-download"></span> <?php echo $this->escape($att->filename); ?>
|
||||
<span class="text-muted">(<?php echo \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::formatSize($att->filesize); ?>)</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
<?php foreach ($t->replies as $reply): ?>
|
||||
<div class="card mb-3 <?php echo $reply->is_internal ? 'border-warning' : ''; ?>">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong><?php echo $this->escape($reply->user_name ?? 'System'); ?></strong>
|
||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, Text::_('DATE_FORMAT_LC2')); ?></small>
|
||||
</div>
|
||||
<?php if ($reply->is_internal): ?>
|
||||
<span class="badge bg-warning text-dark">Internal Note</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php echo nl2br($this->escape($reply->body)); ?>
|
||||
<?php if (!empty($attByReply[$reply->id])): ?>
|
||||
<hr>
|
||||
<div class="small">
|
||||
<strong>Attachments:</strong>
|
||||
<?php foreach ($attByReply[$reply->id] as $att): ?>
|
||||
<a href="<?php echo $downloadUrl . '&id=' . $att->id; ?>" class="d-inline-block me-3">
|
||||
<span class="icon-download"></span> <?php echo $this->escape($att->filename); ?>
|
||||
<span class="text-muted">(<?php echo \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::formatSize($att->filesize); ?>)</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Reply form -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Reply</strong></div>
|
||||
<div class="card-body">
|
||||
<?php if (!empty($canned)): ?>
|
||||
<div class="mb-2">
|
||||
<select class="form-select form-select-sm" id="canned-select">
|
||||
<option value="">Insert canned response...</option>
|
||||
<?php foreach ($canned as $c): ?>
|
||||
<option value="<?php echo $this->escape($c->body); ?>"><?php echo $this->escape($c->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<textarea id="reply-body" class="form-control mb-2" rows="5" placeholder="Type your reply..."></textarea>
|
||||
<div class="mb-2">
|
||||
<input type="file" id="reply-attachments" class="form-control form-control-sm" multiple
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.zip">
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary" id="btn-reply"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.addTicketReply&format=json'); ?>"
|
||||
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>">
|
||||
<span class="icon-reply"></span> Send Reply
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning" id="btn-internal"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.addTicketReply&format=json'); ?>"
|
||||
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" data-internal="1">
|
||||
<span class="icon-eye-slash"></span> Internal Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: ticket metadata -->
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Details</strong></div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
<tr><td class="text-muted">Status</td><td><span class="badge <?php echo $this->escape($t->status_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->status_title ?? $t->status); ?></span></td></tr>
|
||||
<tr><td class="text-muted">Priority</td><td><span class="badge <?php echo $this->escape($t->priority_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->priority_title ?? $t->priority); ?></span></td></tr>
|
||||
<tr><td class="text-muted">Category</td><td><?php echo $this->escape($t->category_title ?? '—'); ?></td></tr>
|
||||
<tr><td class="text-muted">Created By</td><td><?php echo $this->escape($t->created_by_name); ?><br><small><?php echo $this->escape($t->created_by_email ?? ''); ?></small></td></tr>
|
||||
<tr><td class="text-muted">Assigned To</td><td><?php
|
||||
if (!empty($t->assignees)) {
|
||||
foreach ($t->assignees as $a) {
|
||||
$icon = $a->assignee_type === 'group' ? '<span class="icon-users"></span> ' : '<span class="icon-user"></span> ';
|
||||
echo '<div>' . $icon . $this->escape($a->name) . '</div>';
|
||||
}
|
||||
} else {
|
||||
echo '<em>Unassigned</em>';
|
||||
}
|
||||
?></td></tr>
|
||||
<?php if ($t->contact_id): ?>
|
||||
<tr><td class="text-muted">Contact</td><td>
|
||||
<a href="<?php echo Route::_('index.php?option=com_contact&task=contact.edit&id=' . (int) $t->contact_id); ?>">
|
||||
<?php echo $this->escape($t->contact_name ?? 'Contact #' . $t->contact_id); ?>
|
||||
</a>
|
||||
<?php if (!empty($t->contact_email)): ?><br><small><?php echo $this->escape($t->contact_email); ?></small><?php endif; ?>
|
||||
<?php if (!empty($t->contact_phone)): ?><br><small><?php echo $this->escape($t->contact_phone); ?></small><?php endif; ?>
|
||||
</td></tr>
|
||||
<?php endif; ?>
|
||||
<tr><td class="text-muted">Created</td><td><?php echo HTMLHelper::_('date', $t->created, Text::_('DATE_FORMAT_LC2')); ?></td></tr>
|
||||
<?php if ($t->resolved): ?><tr><td class="text-muted">Resolved</td><td><?php echo HTMLHelper::_('date', $t->resolved, Text::_('DATE_FORMAT_LC2')); ?></td></tr><?php endif; ?>
|
||||
<?php if ($t->closed): ?><tr><td class="text-muted">Closed</td><td><?php echo HTMLHelper::_('date', $t->closed, Text::_('DATE_FORMAT_LC2')); ?></td></tr><?php endif; ?>
|
||||
<tr><td class="text-muted">Replies</td><td><?php echo $t->reply_count; ?></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SLA -->
|
||||
<?php if ($t->sla_response_due || $t->sla_resolution_due): ?>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>SLA</strong></div>
|
||||
<div class="card-body">
|
||||
<?php if ($t->sla_response_due): ?>
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Response Due</small><br>
|
||||
<?php
|
||||
$responseOverdue = !$t->sla_responded && strtotime($t->sla_response_due) < time();
|
||||
?>
|
||||
<span class="<?php echo $t->sla_responded ? 'text-success' : ($responseOverdue ? 'text-danger fw-bold' : ''); ?>">
|
||||
<?php echo $t->sla_responded ? 'Responded' : HTMLHelper::_('date', $t->sla_response_due, Text::_('DATE_FORMAT_LC4')); ?>
|
||||
<?php echo $responseOverdue ? ' OVERDUE' : ''; ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($t->sla_resolution_due): ?>
|
||||
<div>
|
||||
<small class="text-muted">Resolution Due</small><br>
|
||||
<?php
|
||||
$resolutionOverdue = !!empty($t->status_is_closed) && strtotime($t->sla_resolution_due) < time();
|
||||
?>
|
||||
<span class="<?php echo !empty($t->status_is_closed) ? 'text-success' : ($resolutionOverdue ? 'text-danger fw-bold' : ''); ?>">
|
||||
<?php echo !empty($t->status_is_closed) ? 'Met' : HTMLHelper::_('date', $t->sla_resolution_due, Text::_('DATE_FORMAT_LC4')); ?>
|
||||
<?php echo $resolutionOverdue ? ' OVERDUE' : ''; ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Satisfaction Rating -->
|
||||
<?php
|
||||
$isClosed = in_array($t->status, ['resolved', 'closed'], true);
|
||||
$hasRating = !empty($t->satisfaction_rating);
|
||||
?>
|
||||
<?php if ($hasRating): ?>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Satisfaction</strong></div>
|
||||
<div class="card-body text-center">
|
||||
<div class="mb-1">
|
||||
<?php for ($s = 1; $s <= 5; $s++): ?>
|
||||
<span style="font-size:1.5rem;color:<?php echo $s <= $t->satisfaction_rating ? '#f5a623' : '#dee2e6'; ?>;">★</span>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<div class="text-muted small"><?php echo $t->satisfaction_rating; ?>/5</div>
|
||||
<?php if (!empty($t->satisfaction_feedback)): ?>
|
||||
<p class="small mt-2 mb-0"><?php echo $this->escape($t->satisfaction_feedback); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($isClosed): ?>
|
||||
<div class="card mb-3" id="rating-card">
|
||||
<div class="card-header"><strong>Rate this Support</strong></div>
|
||||
<div class="card-body text-center">
|
||||
<div class="mb-2" id="star-rating">
|
||||
<?php for ($s = 1; $s <= 5; $s++): ?>
|
||||
<span class="star-btn" data-value="<?php echo $s; ?>" style="font-size:2rem;cursor:pointer;color:#dee2e6;">★</span>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<textarea id="rating-feedback" class="form-control form-control-sm mb-2" rows="2" placeholder="Optional feedback..."></textarea>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="btn-rate"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.rateTicket&format=json'); ?>"
|
||||
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" disabled>
|
||||
Submit Rating
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Status actions -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Actions</strong></div>
|
||||
<div class="card-body d-grid gap-2">
|
||||
<?php foreach ($statuses as $s): ?>
|
||||
<?php if ((int) $s->id !== (int) $t->status_id): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-<?php echo $s->is_closed ? 'danger' : 'secondary'; ?> btn-status"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.updateTicketStatus&format=json'); ?>"
|
||||
data-ticket="<?php echo $t->id; ?>" data-status="<?php echo $s->id; ?>" data-token="<?php echo $token; ?>">
|
||||
<?php echo $this->escape($s->title); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<?php if (!empty($this->customFields)): ?>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Custom Fields</strong></div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
<?php foreach ($this->customFields as $field): ?>
|
||||
<tr>
|
||||
<td class="text-muted"><?php echo $this->escape($field->title); ?></td>
|
||||
<td><?php echo $this->escape($this->fieldValues[(int) $field->id] ?? '—'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Canned response insert
|
||||
var cannedSel = document.getElementById('canned-select');
|
||||
if (cannedSel) {
|
||||
cannedSel.addEventListener('change', function() {
|
||||
if (this.value) { document.getElementById('reply-body').value = this.value; this.selectedIndex = 0; }
|
||||
});
|
||||
}
|
||||
|
||||
// Reply buttons (with attachment upload)
|
||||
document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var body = document.getElementById('reply-body').value.trim();
|
||||
var fileInput = document.getElementById('reply-attachments');
|
||||
if (!body && (!fileInput || !fileInput.files.length)) return;
|
||||
var el = this;
|
||||
el.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', el.dataset.ticket);
|
||||
fd.append('body', body || '(attachment)');
|
||||
fd.append('is_internal', el.dataset.internal || '0');
|
||||
fd.append(el.dataset.token, '1');
|
||||
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (!d.success) { Joomla.renderMessages({error:[d.message]}); el.disabled = false; return; }
|
||||
// Upload attachments if any
|
||||
if (fileInput && fileInput.files.length > 0) {
|
||||
var afd = new FormData();
|
||||
afd.append('ticket_id', el.dataset.ticket);
|
||||
if (d.reply_id) afd.append('reply_id', d.reply_id);
|
||||
for (var i = 0; i < fileInput.files.length; i++) {
|
||||
afd.append('attachments[' + i + ']', fileInput.files[i]);
|
||||
}
|
||||
afd.append(el.dataset.token, '1');
|
||||
fetch('<?php echo $uploadUrl; ?>', {method:'POST', body:afd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(){ location.reload(); });
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function(){ el.disabled = false; });
|
||||
});
|
||||
});
|
||||
|
||||
// Status buttons
|
||||
document.querySelectorAll('.btn-status').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
el.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', el.dataset.ticket);
|
||||
fd.append('status', el.dataset.status);
|
||||
fd.append(el.dataset.token, '1');
|
||||
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
|
||||
.finally(function(){ el.disabled = false; });
|
||||
});
|
||||
});
|
||||
// Star rating
|
||||
var selectedRating = 0;
|
||||
document.querySelectorAll('.star-btn').forEach(function(star) {
|
||||
star.addEventListener('mouseenter', function() {
|
||||
var val = parseInt(this.dataset.value);
|
||||
document.querySelectorAll('.star-btn').forEach(function(s) {
|
||||
s.style.color = parseInt(s.dataset.value) <= val ? '#f5a623' : '#dee2e6';
|
||||
});
|
||||
});
|
||||
star.addEventListener('mouseleave', function() {
|
||||
document.querySelectorAll('.star-btn').forEach(function(s) {
|
||||
s.style.color = parseInt(s.dataset.value) <= selectedRating ? '#f5a623' : '#dee2e6';
|
||||
});
|
||||
});
|
||||
star.addEventListener('click', function() {
|
||||
selectedRating = parseInt(this.dataset.value);
|
||||
document.getElementById('btn-rate').disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
var rateBtn = document.getElementById('btn-rate');
|
||||
if (rateBtn) {
|
||||
rateBtn.addEventListener('click', function() {
|
||||
if (!selectedRating) return;
|
||||
var el = this;
|
||||
el.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', el.dataset.ticket);
|
||||
fd.append('rating', selectedRating);
|
||||
fd.append('feedback', document.getElementById('rating-feedback').value);
|
||||
fd.append(el.dataset.token, '1');
|
||||
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
|
||||
.finally(function(){ el.disabled = false; });
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,317 +0,0 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$tickets = $this->tickets;
|
||||
$categories = $this->categories;
|
||||
$statuses = $this->statuses;
|
||||
$priorities = $this->priorities;
|
||||
$counts = $this->statusCounts;
|
||||
$overdue = $this->overdue;
|
||||
$atsAvailable = $this->atsAvailable;
|
||||
$token = Session::getFormToken();
|
||||
?>
|
||||
|
||||
<div id="mokosuiteclient-tickets">
|
||||
<!-- Status summary cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<?php foreach ($counts as $sc): ?>
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo (int) $sc->cnt; ?></span><small class="text-muted"><?php echo $this->escape($sc->title); ?></small></div></div>
|
||||
<?php endforeach; ?>
|
||||
<?php if (\count($overdue) > 0): ?>
|
||||
<div class="col"><div class="card text-center p-2 border-danger"><span class="fw-bold fs-4 text-danger"><?php echo \count($overdue); ?></span><small class="text-danger">SLA Overdue</small></div></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- New ticket + filters -->
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newTicketModal">
|
||||
<span class="icon-plus"></span> New Ticket
|
||||
</button>
|
||||
<?php if ($atsAvailable): ?>
|
||||
<button type="button" class="btn btn-outline-info" id="btn-import-ats"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.importAts&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>"
|
||||
data-tickets="<?php echo $atsAvailable->tickets; ?>"
|
||||
data-posts="<?php echo $atsAvailable->posts; ?>">
|
||||
<span class="icon-upload"></span> Import from Akeeba (<?php echo $atsAvailable->tickets; ?> tickets, <?php echo $atsAvailable->posts; ?> posts)
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<form method="get" class="d-flex gap-2">
|
||||
<input type="hidden" name="option" value="com_mokosuiteclient">
|
||||
<input type="hidden" name="view" value="tickets">
|
||||
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
|
||||
<option value="">All Statuses</option>
|
||||
<?php foreach ($statuses as $s): ?>
|
||||
<option value="<?php echo $s->id; ?>" <?php echo Factory::getApplication()->getInput()->getInt('filter_status') === (int) $s->id ? 'selected' : ''; ?>><?php echo $this->escape($s->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<select name="filter_priority" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
|
||||
<option value="">All Priorities</option>
|
||||
<?php foreach ($priorities as $p): ?>
|
||||
<option value="<?php echo $p->id; ?>" <?php echo Factory::getApplication()->getInput()->getInt('filter_priority') === (int) $p->id ? 'selected' : ''; ?>><?php echo $this->escape($p->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Ticket table -->
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Subject</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Category</th>
|
||||
<th>Contact</th>
|
||||
<th>Created By</th>
|
||||
<th>Assigned To</th>
|
||||
<th>Created</th>
|
||||
<th>SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($tickets)): ?>
|
||||
<tr><td colspan="10" class="text-center text-muted py-4">No tickets found.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($tickets as $t): ?>
|
||||
<?php
|
||||
$slaClass = '';
|
||||
$now = time();
|
||||
if ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now) $slaClass = 'table-danger';
|
||||
elseif ($t->sla_resolution_due && strtotime($t->sla_resolution_due) < $now && empty($t->status_is_closed)) $slaClass = 'table-danger';
|
||||
elseif ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now + 3600) $slaClass = 'table-warning';
|
||||
?>
|
||||
<tr class="<?php echo $slaClass; ?>">
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=ticket&id=' . $t->id); ?>"><?php echo $this->escape(mb_substr($t->subject, 0, 60)); ?></a></td>
|
||||
<td><span class="badge <?php echo $this->escape($t->status_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->status_title ?? $t->status); ?></span></td>
|
||||
<td><span class="badge <?php echo $this->escape($t->priority_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->priority_title ?? $t->priority); ?></span></td>
|
||||
<td><?php echo $this->escape($t->category_title ?? '—'); ?></td>
|
||||
<td><?php echo $t->contact_name ? '<a href="' . Route::_('index.php?option=com_contact&task=contact.edit&id=' . (int) $t->contact_id) . '">' . $this->escape($t->contact_name) . '</a>' : '—'; ?></td>
|
||||
<td><?php echo $this->escape($t->created_by_name ?? ''); ?></td>
|
||||
<td><?php
|
||||
if (!empty($t->assignees)) {
|
||||
$names = [];
|
||||
foreach ($t->assignees as $a) {
|
||||
$icon = $a->assignee_type === 'group' ? '<span class="icon-users"></span> ' : '';
|
||||
$names[] = $icon . $this->escape($a->name);
|
||||
}
|
||||
echo implode(', ', $names);
|
||||
} else {
|
||||
echo '<em>Unassigned</em>';
|
||||
}
|
||||
?></td>
|
||||
<td class="small"><?php echo HTMLHelper::_('date', $t->created, Text::_('DATE_FORMAT_LC4')); ?></td>
|
||||
<td class="small">
|
||||
<?php if ($t->sla_response_due && !$t->sla_responded): ?>
|
||||
<span title="Response due"><?php echo HTMLHelper::_('date', $t->sla_response_due, Text::_('DATE_FORMAT_LC4')); ?></span>
|
||||
<?php elseif ($t->sla_resolution_due): ?>
|
||||
<span title="Resolution due"><?php echo HTMLHelper::_('date', $t->sla_resolution_due, Text::_('DATE_FORMAT_LC4')); ?></span>
|
||||
<?php else: ?>—<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Ticket Modal -->
|
||||
<div class="modal fade" id="newTicketModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5 class="modal-title">New Ticket</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body">
|
||||
<!-- KB Search step -->
|
||||
<div id="modal-kb-step">
|
||||
<label class="form-label fw-bold">What's the issue?</label>
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" id="modal-kb-search" class="form-control" placeholder="Describe your issue to search for existing answers...">
|
||||
<button type="button" class="btn btn-outline-primary" id="modal-kb-btn"><span class="icon-search"></span></button>
|
||||
</div>
|
||||
<div id="modal-kb-results" class="list-group mb-3 d-none"></div>
|
||||
<button type="button" class="btn btn-primary" id="modal-show-form">
|
||||
<span class="icon-plus"></span> Create Ticket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ticket form step (hidden initially) -->
|
||||
<form id="modal-ticket-form" class="d-none" method="post" action="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.createTicket&format=json'); ?>">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Subject</label>
|
||||
<input type="text" name="subject" id="modal-subject" class="form-control" required>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Category</label>
|
||||
<select name="category_id" class="form-select">
|
||||
<option value="">— Select —</option>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<option value="<?php echo $cat->id; ?>"><?php echo $this->escape($cat->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Priority</label>
|
||||
<select name="priority_id" class="form-select">
|
||||
<?php foreach ($priorities as $p): ?>
|
||||
<option value="<?php echo $p->id; ?>" <?php echo $p->is_default ? 'selected' : ''; ?>><?php echo $this->escape($p->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Contact</label>
|
||||
<select name="contact_id" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
<?php foreach ($this->contacts as $contact): ?>
|
||||
<option value="<?php echo $contact->id; ?>"><?php echo $this->escape($contact->name); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Assign Users</label>
|
||||
<select name="assign_users[]" class="form-select" multiple size="4">
|
||||
<?php foreach ($this->backendUsers as $u): ?>
|
||||
<option value="<?php echo $u->id; ?>"><?php echo $this->escape($u->name); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<small class="text-muted">Hold Ctrl/Cmd to select multiple</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Assign Groups</label>
|
||||
<select name="assign_groups[]" class="form-select" multiple size="4">
|
||||
<?php foreach ($this->userGroups as $g): ?>
|
||||
<option value="<?php echo $g->id; ?>"><?php echo $this->escape($g->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<small class="text-muted">Hold Ctrl/Cmd to select multiple</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="body" class="form-control" rows="6" required></textarea>
|
||||
</div>
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><span class="icon-plus"></span> Create Ticket</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Modal KB search
|
||||
var modalSearch = document.getElementById('modal-kb-search');
|
||||
var modalSearchBtn = document.getElementById('modal-kb-btn');
|
||||
var modalResults = document.getElementById('modal-kb-results');
|
||||
var modalShowForm = document.getElementById('modal-show-form');
|
||||
var modalKbStep = document.getElementById('modal-kb-step');
|
||||
var modalForm = document.getElementById('modal-ticket-form');
|
||||
var modalSubject = document.getElementById('modal-subject');
|
||||
|
||||
function modalDoSearch() {
|
||||
var q = modalSearch.value.trim();
|
||||
if (q.length < 3) return;
|
||||
fetch('<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.searchKb&format=json'); ?>&q=' + encodeURIComponent(q), {
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d) {
|
||||
modalResults.textContent = '';
|
||||
if (d.results && d.results.length > 0) {
|
||||
d.results.forEach(function(item) {
|
||||
var a = document.createElement('a');
|
||||
a.href = item.url;
|
||||
a.target = '_blank';
|
||||
a.className = 'list-group-item list-group-item-action';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = item.title;
|
||||
a.appendChild(strong);
|
||||
if (item.description) {
|
||||
a.appendChild(document.createElement('br'));
|
||||
var small = document.createElement('small');
|
||||
small.className = 'text-muted';
|
||||
small.textContent = item.description;
|
||||
a.appendChild(small);
|
||||
}
|
||||
modalResults.appendChild(a);
|
||||
});
|
||||
modalResults.classList.remove('d-none');
|
||||
} else {
|
||||
modalResults.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
if (modalSearchBtn) modalSearchBtn.addEventListener('click', modalDoSearch);
|
||||
if (modalSearch) modalSearch.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); modalDoSearch(); } });
|
||||
|
||||
// Show ticket form
|
||||
if (modalShowForm) {
|
||||
modalShowForm.addEventListener('click', function() {
|
||||
modalKbStep.classList.add('d-none');
|
||||
modalForm.classList.remove('d-none');
|
||||
if (modalSearch.value && !modalSubject.value) modalSubject.value = modalSearch.value;
|
||||
modalSubject.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Submit ticket from modal
|
||||
if (modalForm) {
|
||||
modalForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var form = this;
|
||||
var fd = new FormData(form);
|
||||
fetch(form.action, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { location.href = 'index.php?option=com_mokosuiteclient&view=ticket&id=' + d.id; }
|
||||
else { Joomla.renderMessages({error:[d.message]}); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Reset modal on close
|
||||
document.getElementById('newTicketModal').addEventListener('hidden.bs.modal', function() {
|
||||
modalKbStep.classList.remove('d-none');
|
||||
modalForm.classList.add('d-none');
|
||||
modalResults.classList.add('d-none');
|
||||
modalSearch.value = '';
|
||||
modalForm.reset();
|
||||
});
|
||||
|
||||
// ATS Import
|
||||
var atsBtn = document.getElementById('btn-import-ats');
|
||||
if (atsBtn) {
|
||||
atsBtn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
if (!confirm('Import ' + el.dataset.tickets + ' tickets and ' + el.dataset.posts + ' posts from Akeeba Ticket System? Duplicates will be skipped.')) return;
|
||||
el.disabled = true;
|
||||
el.textContent = ' Importing...';
|
||||
var fd = new FormData();
|
||||
fd.append(el.dataset.token, '1');
|
||||
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
|
||||
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = 'Import Failed - Retry'; }
|
||||
})
|
||||
.catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -1,203 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
||||
*
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage Component
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$token = Session::getFormToken();
|
||||
|
||||
$colorOptions = [
|
||||
'bg-primary', 'bg-secondary', 'bg-success', 'bg-danger',
|
||||
'bg-warning text-dark', 'bg-info text-dark', 'bg-dark', 'bg-light text-dark',
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="row">
|
||||
<!-- Statuses -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><span class="fa-solid fa-circle-dot"></span> Ticket Statuses</strong>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th class="w-10 text-center">Color</th>
|
||||
<th class="w-10 text-center">Default</th>
|
||||
<th class="w-10 text-center">Closed?</th>
|
||||
<th class="w-10 text-center">Order</th>
|
||||
<th class="w-10 text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->statuses as $s): ?>
|
||||
<tr>
|
||||
<td><?php echo $this->escape($s->title); ?> <small class="text-muted">(<?php echo $this->escape($s->alias); ?>)</small></td>
|
||||
<td class="text-center"><span class="badge <?php echo $this->escape($s->color); ?>"> </span></td>
|
||||
<td class="text-center"><?php echo $s->is_default ? '<span class="badge bg-success">Yes</span>' : ''; ?></td>
|
||||
<td class="text-center"><?php echo $s->is_closed ? '<span class="badge bg-dark">Closed</span>' : ''; ?></td>
|
||||
<td class="text-center"><?php echo (int) $s->ordering; ?></td>
|
||||
<td class="text-center">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="editStatus(<?php echo htmlspecialchars(json_encode($s)); ?>)">
|
||||
<span class="icon-pencil"></span>
|
||||
</button>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.deleteStatus&id=' . $s->id . '&' . $token . '=1'); ?>"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Delete this status?')">
|
||||
<span class="icon-trash"></span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<form method="post" action="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.saveStatus'); ?>" id="statusForm" class="row g-2 align-items-end">
|
||||
<input type="hidden" name="id" id="status-id" value="0">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" name="title" id="status-title" class="form-control form-control-sm" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Alias</label>
|
||||
<input type="text" name="alias" id="status-alias" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Color</label>
|
||||
<select name="color" id="status-color" class="form-select form-select-sm">
|
||||
<?php foreach ($colorOptions as $c): ?>
|
||||
<option value="<?php echo $c; ?>"><?php echo $c; ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Order</label>
|
||||
<input type="number" name="ordering" id="status-ordering" class="form-control form-control-sm" value="0">
|
||||
</div>
|
||||
<div class="col-md-1 text-center">
|
||||
<label class="form-label small">Default</label>
|
||||
<input type="checkbox" name="is_default" id="status-default" value="1" class="form-check-input">
|
||||
</div>
|
||||
<div class="col-md-1 text-center">
|
||||
<label class="form-label small">Closed</label>
|
||||
<input type="checkbox" name="is_closed" id="status-closed" value="1" class="form-check-input">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100" id="status-btn">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priorities -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><span class="fa-solid fa-flag"></span> Ticket Priorities</strong>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th class="w-10 text-center">Color</th>
|
||||
<th class="w-10 text-center">Default</th>
|
||||
<th class="w-10 text-center">Order</th>
|
||||
<th class="w-10 text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->priorities as $p): ?>
|
||||
<tr>
|
||||
<td><?php echo $this->escape($p->title); ?> <small class="text-muted">(<?php echo $this->escape($p->alias); ?>)</small></td>
|
||||
<td class="text-center"><span class="badge <?php echo $this->escape($p->color); ?>"> </span></td>
|
||||
<td class="text-center"><?php echo $p->is_default ? '<span class="badge bg-success">Yes</span>' : ''; ?></td>
|
||||
<td class="text-center"><?php echo (int) $p->ordering; ?></td>
|
||||
<td class="text-center">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="editPriority(<?php echo htmlspecialchars(json_encode($p)); ?>)">
|
||||
<span class="icon-pencil"></span>
|
||||
</button>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.deletePriority&id=' . $p->id . '&' . $token . '=1'); ?>"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Delete this priority?')">
|
||||
<span class="icon-trash"></span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<form method="post" action="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.savePriority'); ?>" id="priorityForm" class="row g-2 align-items-end">
|
||||
<input type="hidden" name="id" id="priority-id" value="0">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" name="title" id="priority-title" class="form-control form-control-sm" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Alias</label>
|
||||
<input type="text" name="alias" id="priority-alias" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Color</label>
|
||||
<select name="color" id="priority-color" class="form-select form-select-sm">
|
||||
<?php foreach ($colorOptions as $c): ?>
|
||||
<option value="<?php echo $c; ?>"><?php echo $c; ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Order</label>
|
||||
<input type="number" name="ordering" id="priority-ordering" class="form-control form-control-sm" value="0">
|
||||
</div>
|
||||
<div class="col-md-1 text-center">
|
||||
<label class="form-label small">Default</label>
|
||||
<input type="checkbox" name="is_default" id="priority-default" value="1" class="form-check-input">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100" id="priority-btn">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function editStatus(s) {
|
||||
document.getElementById('status-id').value = s.id;
|
||||
document.getElementById('status-title').value = s.title;
|
||||
document.getElementById('status-alias').value = s.alias;
|
||||
document.getElementById('status-color').value = s.color;
|
||||
document.getElementById('status-ordering').value = s.ordering;
|
||||
document.getElementById('status-default').checked = !!parseInt(s.is_default);
|
||||
document.getElementById('status-closed').checked = !!parseInt(s.is_closed);
|
||||
document.getElementById('status-btn').textContent = 'Update';
|
||||
}
|
||||
function editPriority(p) {
|
||||
document.getElementById('priority-id').value = p.id;
|
||||
document.getElementById('priority-title').value = p.title;
|
||||
document.getElementById('priority-alias').value = p.alias;
|
||||
document.getElementById('priority-color').value = p.color;
|
||||
document.getElementById('priority-ordering').value = p.ordering;
|
||||
document.getElementById('priority-default').checked = !!parseInt(p.is_default);
|
||||
document.getElementById('priority-btn').textContent = 'Update';
|
||||
}
|
||||
</script>
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -20,7 +20,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>MokoSuiteClient admin dashboard and REST API. Provides a control panel for managing MokoSuiteClient feature plugins, site health monitoring, and remote management endpoints.</description>
|
||||
|
||||
<namespace path="src">Moko\Component\MokoSuiteClient</namespace>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>MOD_MOKOSUITECLIENT_CACHE_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoSuiteClientCache</namespace>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>MOD_MOKOSUITECLIENT_CATEGORIES_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoSuiteClientCategories</namespace>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>MOD_MOKOSUITECLIENT_CPANEL_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoSuiteClientCpanel</namespace>
|
||||
|
||||
|
||||
@@ -44,10 +44,13 @@ foreach ($plugins as $p)
|
||||
}
|
||||
|
||||
$labels = [
|
||||
'mokosuiteclient' => 'Core',
|
||||
'mokosuiteclient_firewall' => 'Firewall',
|
||||
'mokosuiteclient_tenant' => 'Tenant',
|
||||
'mokosuiteclient_devtools' => 'DevTools',
|
||||
'mokosuiteclient' => 'Core Engine',
|
||||
'mokosuiteclient_firewall' => 'Web Firewall',
|
||||
'mokosuiteclient_tenant' => 'Tenant Guard',
|
||||
'mokosuiteclient_devtools' => 'Dev Tools',
|
||||
'mokosuiteclient_offline' => 'Offline Bypass',
|
||||
'mokosuiteclient_dbip' => 'GeoIP Lookup',
|
||||
'mokosuiteclient_license' => 'License Manager',
|
||||
];
|
||||
|
||||
$diskPct = ($disk->total_mb && $disk->total_mb > 0)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>MokoSuiteClient admin sidebar menu — renders a dedicated MokoSuiteClient section in the admin menu before Joomla's default menu.</description>
|
||||
<namespace path="src">Moko\Module\MokoSuiteClientMenu</namespace>
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ $currentView = $app->getInput()->get('view', '');
|
||||
// ── Static views for com_mokosuiteclient ──────────────────────────────────
|
||||
$mokosuiteclientStaticViews = [
|
||||
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient'],
|
||||
['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokosuiteclient&view=tickets'],
|
||||
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuiteclient&view=extensions'],
|
||||
['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokosuiteclient&view=htaccess'],
|
||||
['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokosuiteclient&view=privacy'],
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
* VERSION: 02.46.80
|
||||
* VERSION: 02.46.95
|
||||
* PATH: /src/Extension/MokoSuiteClient.php
|
||||
* NOTE: Core system plugin for MokoSuiteClient admin tools suite
|
||||
*/
|
||||
@@ -1879,10 +1879,16 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
|
||||
*/
|
||||
protected function isDevAlias(): bool
|
||||
{
|
||||
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
$devDomain = $this->getDevAliasDomain();
|
||||
$devAlias = $this->getDevAliasConfig();
|
||||
|
||||
return !empty($devDomain) && strcasecmp($currentHost, $devDomain) === 0;
|
||||
if ($devAlias === null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
|
||||
return !empty($currentHost) && strcasecmp($currentHost, $devAlias['domain']) === 0;
|
||||
}
|
||||
|
||||
protected function getCurrentAlias()
|
||||
@@ -1967,32 +1973,51 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
|
||||
*/
|
||||
protected function handleSiteAlias()
|
||||
{
|
||||
// The dev alias (dev.{primary_domain}) always bypasses offline mode
|
||||
if ($this->isDevAlias())
|
||||
$devAlias = $this->getDevAliasConfig();
|
||||
|
||||
if ($devAlias === null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if current request is on the dev domain
|
||||
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
|
||||
if (empty($currentHost) || strcasecmp($currentHost, $devAlias['domain']) !== 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Bypass offline mode if enabled
|
||||
if (!empty($devAlias['bypass_offline']))
|
||||
{
|
||||
$this->app->getConfig()->set('offline', 0);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject robots meta tag for alias domains.
|
||||
*
|
||||
* @param \Joomla\CMS\Document\HtmlDocument $doc Document object
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.43
|
||||
*/
|
||||
protected function injectAliasRobots($doc)
|
||||
{
|
||||
// Always noindex/nofollow on the dev alias domain
|
||||
if ($this->isDevAlias())
|
||||
$devAlias = $this->getDevAliasConfig();
|
||||
|
||||
if ($devAlias === null)
|
||||
{
|
||||
$doc->setMetaData('robots', 'noindex, nofollow');
|
||||
return;
|
||||
}
|
||||
|
||||
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
|
||||
if (empty($currentHost) || strcasecmp($currentHost, $devAlias['domain']) !== 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Set robots directive from devtools config
|
||||
$robots = $devAlias['robots'] ?? 'noindex, nofollow';
|
||||
$doc->setMetaData('robots', $robots);
|
||||
|
||||
// Inject canonical URL pointing to the primary domain
|
||||
$primaryHost = $this->getPrimaryHost();
|
||||
$currentUri = Uri::getInstance();
|
||||
@@ -2000,6 +2025,98 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
|
||||
$doc->addHeadLink($canonical, 'canonical');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the alias config matching the current request domain.
|
||||
*
|
||||
* Reads the site_aliases subform from DevTools plugin params.
|
||||
* Each entry has: domain, offline_bypass, robots, label.
|
||||
* Also auto-includes dev.{primary_domain} if no aliases are configured.
|
||||
*
|
||||
* @return array|null ['domain' => '...', 'bypass_offline' => bool, 'robots' => '...', 'label' => '...'] or null
|
||||
*/
|
||||
private function getDevAliasConfig(): ?array
|
||||
{
|
||||
static $config = false;
|
||||
|
||||
if ($config !== false)
|
||||
{
|
||||
return $config;
|
||||
}
|
||||
|
||||
$config = null;
|
||||
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
|
||||
if (empty($currentHost))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient_devtools'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
);
|
||||
$devParams = json_decode((string) $db->loadResult(), true) ?: [];
|
||||
$aliases = $devParams['site_aliases'] ?? [];
|
||||
|
||||
// Normalize — Joomla subform stores as object or array
|
||||
if (\is_object($aliases))
|
||||
{
|
||||
$aliases = (array) $aliases;
|
||||
}
|
||||
|
||||
// Check each configured alias against current host
|
||||
foreach ($aliases as $entry)
|
||||
{
|
||||
$entry = (array) $entry;
|
||||
$domain = trim($entry['domain'] ?? '');
|
||||
|
||||
if (!empty($domain) && strcasecmp($currentHost, $domain) === 0)
|
||||
{
|
||||
$config = [
|
||||
'domain' => $domain,
|
||||
'bypass_offline' => ($entry['offline_bypass'] ?? '1') === '1',
|
||||
'robots' => $entry['robots'] ?? 'noindex, nofollow',
|
||||
'label' => $entry['label'] ?? '',
|
||||
];
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-include dev.{primary_domain} if no aliases configured
|
||||
if (empty($aliases))
|
||||
{
|
||||
$primary = $this->getPrimaryHost();
|
||||
$devDomain = !empty($primary) ? 'dev.' . $primary : '';
|
||||
|
||||
if (!empty($devDomain) && strcasecmp($currentHost, $devDomain) === 0)
|
||||
{
|
||||
$config = [
|
||||
'domain' => $devDomain,
|
||||
'bypass_offline' => true,
|
||||
'robots' => 'noindex, nofollow',
|
||||
'label' => 'Development',
|
||||
];
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$config = null;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Heartbeat (called from onExtensionAfterSave)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoSuiteClient
|
||||
* VERSION: 02.46.80
|
||||
* VERSION: 02.46.95
|
||||
* PATH: /src/Field/CopyableTokenField.php
|
||||
* BRIEF: Read-only token field with a copy-to-clipboard button
|
||||
*/
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>MokoSuiteClient core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description>
|
||||
<namespace path=".">Moko\Plugin\System\MokoSuiteClient</namespace>
|
||||
<scriptfile>script.php</scriptfile>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
* VERSION: 02.46.80
|
||||
* VERSION: 02.46.95
|
||||
* PATH: /src/script.php
|
||||
* BRIEF: Installation script for MokoSuiteClient plugin
|
||||
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
* VERSION: 02.46.80
|
||||
* VERSION: 02.46.95
|
||||
* PATH: /src/services/provider.php
|
||||
* BRIEF: Service provider for dependency injection in Joomla 5.x
|
||||
* NOTE: Registers the plugin with Joomla's DI container
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientBackup</namespace>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDBIP</namespace>
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<form>
|
||||
<field name="domain" type="text"
|
||||
label="Domain"
|
||||
hint="dev.clientsite.com"
|
||||
filter="raw"
|
||||
required="true" />
|
||||
|
||||
<field name="offline_bypass" type="list" default="1"
|
||||
label="Offline Mode">
|
||||
<option value="1">Bypass (stay online)</option>
|
||||
<option value="0">Respect (go offline)</option>
|
||||
</field>
|
||||
|
||||
<field name="robots" type="list" default="noindex, nofollow"
|
||||
label="Robots">
|
||||
<option value="noindex, nofollow">noindex, nofollow</option>
|
||||
<option value="noindex">noindex</option>
|
||||
<option value="index, follow">index, follow (production)</option>
|
||||
</field>
|
||||
|
||||
<field name="label" type="text"
|
||||
label="Label"
|
||||
hint="Development, Staging, QA..."
|
||||
filter="string" />
|
||||
</form>
|
||||
+4
-10
@@ -16,13 +16,7 @@ PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DELETE_VERSIONS_DESC="One-shot: delete all c
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_DLKEYS_LABEL="Reset Download Keys"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_DLKEYS_DESC="One-shot: clear all download keys (dlid) from update sites on save. Automatically turns off after execution."
|
||||
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_DEVDOMAIN="Dev Domain"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_DEVDOMAIN_DESC="Configure a development domain alias that bypasses offline mode and has its own robots settings."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ENABLED_LABEL="Enable Dev Domain"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ENABLED_DESC="Allow a development domain to bypass offline mode for testing."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_LABEL="Dev Domain"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_DESC="The development domain alias. Leave empty to auto-detect as dev.{primary_domain}. Must point to the same hosting folder."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_BYPASS_LABEL="Bypass Offline Mode"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_BYPASS_DESC="When the main site is offline, the dev domain stays accessible for development and testing."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ROBOTS_LABEL="Robots Directive"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ROBOTS_DESC="Meta robots tag for the dev domain. Use noindex,nofollow to prevent search engines from indexing the dev site."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_ALIASES="Domain Aliases & Staging"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_ALIASES_DESC="Configure domain aliases (dev, staging, QA) that can bypass offline mode and have their own robots settings. Each alias must point to the same hosting folder via CNAME or A record."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_ALIASES_LABEL="Site Aliases"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_ALIASES_DESC="Add domain aliases for development, staging, or QA environments. Each alias can independently bypass offline mode and set its own robots directive."
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDevTools</namespace>
|
||||
|
||||
<files>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>forms</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
@@ -62,41 +63,18 @@
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="dev_domain"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_DEVDOMAIN"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_DEVDOMAIN_DESC">
|
||||
<fieldset name="site_aliases"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_ALIASES"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_ALIASES_DESC">
|
||||
|
||||
<field name="dev_domain_enabled" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ENABLED_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ENABLED_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="dev_domain" type="text" default=""
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_DESC"
|
||||
hint="dev.clientsite.com (auto-detected from primary domain if empty)"
|
||||
showon="dev_domain_enabled:1" />
|
||||
|
||||
<field name="dev_domain_offline_bypass" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_BYPASS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_BYPASS_DESC"
|
||||
class="btn-group btn-group-yesno"
|
||||
showon="dev_domain_enabled:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="dev_domain_robots" type="list" default="noindex, nofollow"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ROBOTS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEVDOMAIN_ROBOTS_DESC"
|
||||
showon="dev_domain_enabled:1">
|
||||
<option value="noindex, nofollow">noindex, nofollow</option>
|
||||
<option value="noindex">noindex</option>
|
||||
<option value="index, follow">index, follow</option>
|
||||
</field>
|
||||
<field name="site_aliases" type="subform"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_ALIASES_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_ALIASES_DESC"
|
||||
formsource="plugins/system/mokosuiteclient_devtools/forms/site_alias_entry.xml"
|
||||
multiple="true"
|
||||
layout="joomla.form.field.subform.repeatable-table"
|
||||
groupByFieldset="false"
|
||||
buttons="add,remove,move" />
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientFirewall</namespace>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientLicense</namespace>
|
||||
<files><folder>src</folder><folder>services</folder><folder>language</folder></files>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientOffline</namespace>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientTenant</namespace>
|
||||
|
||||
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS="Task - MokoSuiteClient Ticket Automation"
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_DESC="Runs scheduled helpdesk automation rules."
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION_TITLE="MokoSuiteClient: Ticket Automation"
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION_DESC="Runs time-based automation rules against open tickets (auto-close, SLA escalation, etc.)."
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL_TITLE="MokoSuiteClient: IMAP Email Polling"
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL_DESC="Polls an IMAP inbox for new emails and creates tickets or replies from unread messages."
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE_TITLE="MokoSuiteClient: Auto-Close Resolved Tickets"
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE_DESC="Automatically closes tickets that have been in resolved status longer than the configured number of days."
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS="Task - MokoSuiteClient Ticket Automation"
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_DESC="Runs scheduled helpdesk automation rules — auto-close, SLA escalation, and time-based actions."
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="plugin" group="task" method="upgrade">
|
||||
<name>Task - MokoSuiteClient Ticket Automation</name>
|
||||
<element>mokosuiteclient_tickets</element>
|
||||
<author>Moko Consulting</author>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<description>Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.</description>
|
||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientTickets</namespace>
|
||||
|
||||
<files>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/plg_task_mokosuiteclient_tickets.ini</language>
|
||||
<language tag="en-GB">en-GB/plg_task_mokosuiteclient_tickets.sys.ini</language>
|
||||
</languages>
|
||||
</extension>
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Extension\PluginInterface;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
use Joomla\Event\DispatcherInterface;
|
||||
use Moko\Plugin\Task\MokoSuiteClientTickets\Extension\TicketAutomation;
|
||||
|
||||
return new class implements ServiceProviderInterface
|
||||
{
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->set(
|
||||
PluginInterface::class,
|
||||
function (Container $container) {
|
||||
$dispatcher = $container->get(DispatcherInterface::class);
|
||||
$plugin = new TicketAutomation($dispatcher, (array) PluginHelper::getPlugin('task', 'mokosuiteclient_tickets'));
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,367 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage plg_task_mokosuiteclient_tickets
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\Task\MokoSuiteClientTickets\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
|
||||
use Joomla\Component\Scheduler\Administrator\Task\Status;
|
||||
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
use Moko\Component\MokoSuiteClient\Administrator\Model\TicketsModel;
|
||||
use Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService;
|
||||
use Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService;
|
||||
|
||||
class TicketAutomation extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
use TaskPluginTrait;
|
||||
|
||||
protected const TASKS_MAP = [
|
||||
'mokosuiteclient.ticket.automation' => [
|
||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION',
|
||||
'method' => 'runAutomation',
|
||||
],
|
||||
'mokosuiteclient.ticket.imap_poll' => [
|
||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL',
|
||||
'method' => 'runImapPoll',
|
||||
],
|
||||
'mokosuiteclient.ticket.autoclose' => [
|
||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE',
|
||||
'method' => 'runAutoClose',
|
||||
],
|
||||
'mokosuiteclient.license.validate' => [
|
||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_LICENSE_VALIDATE',
|
||||
'method' => 'runLicenseValidation',
|
||||
],
|
||||
'mokosuiteclient.license.heartbeat' => [
|
||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_LICENSE_HEARTBEAT',
|
||||
'method' => 'runLicenseHeartbeat',
|
||||
],
|
||||
];
|
||||
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onTaskOptionsList' => 'advertiseRoutines',
|
||||
'onExecuteTask' => 'standardRoutineHandler',
|
||||
'onContentPrepareForm' => 'enhanceTaskItemForm',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all scheduled automation rules against open tickets.
|
||||
*/
|
||||
private function runAutomation(ExecuteTaskEvent $event): int
|
||||
{
|
||||
try
|
||||
{
|
||||
$model = new TicketsModel();
|
||||
$results = $model->runScheduledAutomation();
|
||||
|
||||
$this->logTask(
|
||||
\sprintf('Ticket automation: evaluated %d tickets, acted on %d', $results['evaluated'], $results['acted'])
|
||||
);
|
||||
|
||||
return Status::OK;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->logTask('Ticket automation failed: ' . $e->getMessage(), 'error');
|
||||
|
||||
return Status::KNOCKOUT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll IMAP inbox and create tickets from unread emails (#136).
|
||||
*/
|
||||
private function runImapPoll(ExecuteTaskEvent $event): int
|
||||
{
|
||||
$config = $this->getComponentConfig();
|
||||
$host = $config['imap_host'] ?? '';
|
||||
$port = (int) ($config['imap_port'] ?? 993);
|
||||
$user = $config['imap_user'] ?? '';
|
||||
$pass = $config['imap_password'] ?? '';
|
||||
$ssl = ($config['imap_ssl'] ?? '1') === '1';
|
||||
$folder = $config['imap_folder'] ?? 'INBOX';
|
||||
$processed = $config['imap_processed_folder'] ?? 'INBOX.Processed';
|
||||
$defaultCat = (int) ($config['default_category'] ?? 0) ?: null;
|
||||
|
||||
if (empty($host) || empty($user) || empty($pass))
|
||||
{
|
||||
$this->logTask('IMAP not configured — skipping', 'warning');
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
if (!function_exists('imap_open'))
|
||||
{
|
||||
$this->logTask('php-imap extension not available', 'error');
|
||||
return Status::KNOCKOUT;
|
||||
}
|
||||
|
||||
$mailbox = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}' . $folder;
|
||||
$mbox = @imap_open($mailbox, $user, $pass);
|
||||
|
||||
if (!$mbox)
|
||||
{
|
||||
$this->logTask('IMAP connection failed: ' . imap_last_error(), 'error');
|
||||
return Status::KNOCKOUT;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$created = 0;
|
||||
$replied = 0;
|
||||
|
||||
$emails = imap_search($mbox, 'UNSEEN');
|
||||
|
||||
if ($emails === false)
|
||||
{
|
||||
imap_close($mbox);
|
||||
$this->logTask('No new emails');
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
foreach ($emails as $msgNum)
|
||||
{
|
||||
try
|
||||
{
|
||||
$header = imap_headerinfo($mbox, $msgNum);
|
||||
$subject = isset($header->subject) ? imap_utf8($header->subject) : '(no subject)';
|
||||
$fromAddr = $header->from[0]->mailbox . '@' . $header->from[0]->host;
|
||||
$body = $this->getImapBody($mbox, $msgNum);
|
||||
|
||||
// Match sender to Joomla user
|
||||
$userId = $this->findUserByEmail($fromAddr);
|
||||
|
||||
// Check if this is a reply (subject contains [#123])
|
||||
$ticketId = 0;
|
||||
if (preg_match('/\[#(\d+)\]/', $subject, $m))
|
||||
{
|
||||
$ticketId = (int) $m[1];
|
||||
}
|
||||
|
||||
if ($ticketId > 0)
|
||||
{
|
||||
// Add as reply to existing ticket
|
||||
$reply = (object) [
|
||||
'ticket_id' => $ticketId,
|
||||
'user_id' => $userId,
|
||||
'body' => $body,
|
||||
'is_internal' => 0,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply, 'id');
|
||||
$replied++;
|
||||
|
||||
// Notify
|
||||
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_tickets')->where('id = ' . $ticketId));
|
||||
$ticket = $db->loadObject();
|
||||
if ($ticket) {
|
||||
NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create new ticket
|
||||
$ticket = (object) [
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'status' => 'open',
|
||||
'priority' => 'normal',
|
||||
'category_id' => $defaultCat,
|
||||
'created_by' => $userId,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
|
||||
$created++;
|
||||
|
||||
NotificationService::notify('ticket_created', $ticket);
|
||||
}
|
||||
|
||||
// Mark as seen / move to processed folder
|
||||
imap_setflag_full($mbox, (string) $msgNum, '\\Seen');
|
||||
|
||||
if ($processed && $processed !== $folder)
|
||||
{
|
||||
@imap_mail_move($mbox, (string) $msgNum, $processed);
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('IMAP message processing error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
imap_expunge($mbox);
|
||||
imap_close($mbox);
|
||||
|
||||
$this->logTask("IMAP poll: {$created} tickets created, {$replied} replies added");
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-close resolved tickets after configured days.
|
||||
*/
|
||||
private function runAutoClose(ExecuteTaskEvent $event): int
|
||||
{
|
||||
$config = $this->getComponentConfig();
|
||||
$days = (int) ($config['autoclose_days'] ?? 7);
|
||||
|
||||
if ($days <= 0)
|
||||
{
|
||||
$this->logTask('Auto-close disabled (days = 0)');
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
|
||||
|
||||
$db->setQuery(
|
||||
"UPDATE {$db->quoteName('#__mokosuiteclient_tickets')}"
|
||||
. " SET status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}"
|
||||
. " WHERE status = 'resolved'"
|
||||
. " AND resolved IS NOT NULL"
|
||||
. " AND resolved < {$db->quote($cutoff)}"
|
||||
);
|
||||
$db->execute();
|
||||
$closed = $db->getAffectedRows();
|
||||
|
||||
$this->logTask("Auto-close: {$closed} tickets closed (resolved > {$days} days ago)");
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
private function getComponentConfig(): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('params')
|
||||
->from('#__extensions')
|
||||
->where('element = ' . $db->quote('com_mokosuiteclient'))
|
||||
->where('type = ' . $db->quote('component'))
|
||||
);
|
||||
return json_decode($db->loadResult() ?? '{}', true) ?: [];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Failed to load component config: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function findUserByEmail(string $email): int
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('id')
|
||||
->from('#__users')
|
||||
->where('email = ' . $db->quote($email))
|
||||
->setLimit(1)
|
||||
);
|
||||
return (int) $db->loadResult();
|
||||
}
|
||||
|
||||
private function getImapBody($mbox, int $msgNum): string
|
||||
{
|
||||
$structure = imap_fetchstructure($mbox, $msgNum);
|
||||
|
||||
// Simple single-part message
|
||||
if (empty($structure->parts))
|
||||
{
|
||||
$body = imap_fetchbody($mbox, $msgNum, '1');
|
||||
if ($structure->encoding === 3) $body = base64_decode($body);
|
||||
if ($structure->encoding === 4) $body = quoted_printable_decode($body);
|
||||
return trim(strip_tags($body));
|
||||
}
|
||||
|
||||
// Multipart — find text/plain or text/html
|
||||
$textBody = '';
|
||||
|
||||
foreach ($structure->parts as $i => $part)
|
||||
{
|
||||
$partNum = (string) ($i + 1);
|
||||
|
||||
if ($part->type === 0) // text
|
||||
{
|
||||
$content = imap_fetchbody($mbox, $msgNum, $partNum);
|
||||
if ($part->encoding === 3) $content = base64_decode($content);
|
||||
if ($part->encoding === 4) $content = quoted_printable_decode($content);
|
||||
|
||||
$subtype = strtolower($part->subtype ?? '');
|
||||
|
||||
if ($subtype === 'plain' && empty($textBody))
|
||||
{
|
||||
$textBody = $content;
|
||||
}
|
||||
elseif ($subtype === 'html' && empty($textBody))
|
||||
{
|
||||
$textBody = strip_tags($content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return trim($textBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily license revalidation — refresh cached license status from MokoGitea.
|
||||
* Recommended schedule: daily at 3:00 AM.
|
||||
*/
|
||||
private function runLicenseValidation(ExecuteTaskEvent $event): int
|
||||
{
|
||||
try {
|
||||
$result = \Moko\Plugin\System\MokoSuiteClient\Helper\LicenseValidator::validate(true);
|
||||
|
||||
$status = $result->valid ? 'valid' : ($result->status ?? 'invalid');
|
||||
$tier = $result->tier ?? 'none';
|
||||
$entitlements = count($result->entitlements ?? []);
|
||||
|
||||
$this->logTask(sprintf(
|
||||
'License validation: status=%s tier=%s entitlements=%d',
|
||||
$status, $tier, $entitlements
|
||||
));
|
||||
|
||||
if (!$result->valid) {
|
||||
Log::add('License validation failed: ' . ($result->message ?? 'unknown'), Log::WARNING, 'mokosuite.license');
|
||||
}
|
||||
|
||||
return Status::OK;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logTask('License validation error: ' . $e->getMessage());
|
||||
return Status::KNOCKOUT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily heartbeat — report active installation to MokoGitea.
|
||||
* Recommended schedule: daily at 4:00 AM.
|
||||
*/
|
||||
private function runLicenseHeartbeat(ExecuteTaskEvent $event): int
|
||||
{
|
||||
try {
|
||||
$result = \Moko\Plugin\System\MokoSuiteClient\Helper\LicenseValidator::heartbeat();
|
||||
|
||||
$this->logTask('License heartbeat: ' . ($result->success ?? false ? 'sent' : ($result->error ?? 'failed')));
|
||||
return Status::OK;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logTask('License heartbeat error: ' . $e->getMessage());
|
||||
return Status::KNOCKOUT;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>PLG_TASK_MOKOSUITECLIENTDEMO_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientDemo</namespace>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||
* PATH: /src/packages/plg_system_mokosuiteclient/Service/DemoResetService.php
|
||||
* VERSION: 02.46.80
|
||||
* VERSION: 02.46.95
|
||||
* BRIEF: Content-only snapshot/restore for demo site reset
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>PLG_TASK_MOKOSUITECLIENTSYNC_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientSync</namespace>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||
* PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncReceiver.php
|
||||
* VERSION: 02.46.80
|
||||
* VERSION: 02.46.95
|
||||
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||
* PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncService.php
|
||||
* VERSION: 02.46.80
|
||||
* VERSION: 02.46.95
|
||||
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
|
||||
*/
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<description>Joomla Web Services API routes for MokoSuiteClient site management — health checks, cache, updates, backups, and site info.</description>
|
||||
<namespace path="src">Moko\Plugin\WebServices\MokoSuiteClient</namespace>
|
||||
<files>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuiteClient</name>
|
||||
<packagename>mokosuiteclient</packagename>
|
||||
<version>02.46.80</version>
|
||||
<version>02.46.95</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -28,7 +28,6 @@
|
||||
<file type="plugin" id="plg_webservices_mokosuiteclient" group="webservices">plg_webservices_mokosuiteclient.zip</file>
|
||||
<file type="plugin" id="plg_task_mokosuiteclientdemo" group="task">plg_task_mokosuiteclientdemo.zip</file>
|
||||
<file type="plugin" id="plg_task_mokosuiteclientsync" group="task">plg_task_mokosuiteclientsync.zip</file>
|
||||
<file type="plugin" id="plg_task_mokosuiteclient_tickets" group="task">plg_task_mokosuiteclient_tickets.zip</file>
|
||||
</files>
|
||||
|
||||
<updateservers>
|
||||
|
||||
+97
-55
@@ -92,7 +92,6 @@ class Pkg_MokosuiteclientInstallerScript
|
||||
$this->enablePlugin('webservices', 'mokosuiteclient');
|
||||
$this->enablePlugin('task', 'mokosuiteclientdemo');
|
||||
$this->enablePlugin('task', 'mokosuiteclientsync');
|
||||
$this->enablePlugin('task', 'mokosuiteclient_tickets');
|
||||
|
||||
// Migrate params from core plugin to feature plugins (one-time)
|
||||
$this->migrateFeatureParams();
|
||||
@@ -521,47 +520,8 @@ class Pkg_MokosuiteclientInstallerScript
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($element))
|
||||
);
|
||||
|
||||
if ((int) $db->loadResult() > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// No row exists — create one from the manifest XML on disk
|
||||
$manifestFile = $pluginDir . '/' . $element . '.xml';
|
||||
|
||||
if (is_file($manifestFile))
|
||||
{
|
||||
$xml = @simplexml_load_file($manifestFile);
|
||||
$name = $xml ? (string) ($xml->name ?? $manifestName) : $manifestName;
|
||||
$namespace = $xml ? (string) ($xml->namespace ?? '') : '';
|
||||
|
||||
$row = (object) [
|
||||
'name' => $name,
|
||||
'type' => 'plugin',
|
||||
'element' => $element,
|
||||
'folder' => $group,
|
||||
'client_id' => 0,
|
||||
'enabled' => 1,
|
||||
'access' => 1,
|
||||
'protected' => 0,
|
||||
'locked' => 0,
|
||||
'params' => '{}',
|
||||
'manifest_cache' => '{}',
|
||||
'custom_data' => '',
|
||||
'state' => 0,
|
||||
'ordering' => 0,
|
||||
'checked_out' => null,
|
||||
'checked_out_time' => null,
|
||||
];
|
||||
|
||||
if (!empty($namespace))
|
||||
{
|
||||
$row->namespace = $namespace;
|
||||
}
|
||||
|
||||
$db->insertObject('#__extensions', $row, 'extension_id');
|
||||
Log::add('Created extension record for plugin ' . $group . '/' . $element, Log::INFO, 'mokosuiteclient');
|
||||
}
|
||||
// No row exists — reinstallBrokenPlugins() will handle it via
|
||||
// Joomla's Installer which properly registers the namespace
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
@@ -680,8 +640,8 @@ class Pkg_MokosuiteclientInstallerScript
|
||||
|
||||
try
|
||||
{
|
||||
$installer = $this->installerParent->getParent();
|
||||
$sourceDir = $installer->getPath('source');
|
||||
$parentInstaller = $this->installerParent->getParent();
|
||||
$sourceDir = $parentInstaller->getPath('source');
|
||||
|
||||
if (empty($sourceDir) || !is_dir($sourceDir . '/packages'))
|
||||
{
|
||||
@@ -691,20 +651,13 @@ class Pkg_MokosuiteclientInstallerScript
|
||||
// Plugins that should exist on disk
|
||||
$expected = [
|
||||
'system' => ['mokosuiteclient_offline', 'mokosuiteclient_firewall', 'mokosuiteclient_tenant', 'mokosuiteclient_devtools', 'mokosuiteclient_dbip'],
|
||||
'task' => ['mokosuiteclient_tickets'],
|
||||
'task' => [],
|
||||
];
|
||||
|
||||
foreach ($expected as $group => $elements)
|
||||
{
|
||||
foreach ($elements as $element)
|
||||
{
|
||||
$pluginDir = JPATH_PLUGINS . '/' . $group . '/' . $element;
|
||||
|
||||
if (is_dir($pluginDir))
|
||||
{
|
||||
continue; // Already installed correctly
|
||||
}
|
||||
|
||||
$zipName = 'plg_' . $group . '_' . $element . '.zip';
|
||||
$zipPath = $sourceDir . '/packages/' . $zipName;
|
||||
|
||||
@@ -713,7 +666,7 @@ class Pkg_MokosuiteclientInstallerScript
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the zip to the correct plugin directory
|
||||
// Extract to plugin dir
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($zipPath) !== true)
|
||||
@@ -721,11 +674,101 @@ class Pkg_MokosuiteclientInstallerScript
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_dir($pluginDir))
|
||||
{
|
||||
$this->rmdirRecursive($pluginDir);
|
||||
}
|
||||
|
||||
@mkdir($pluginDir, 0755, true);
|
||||
$zip->extractTo($pluginDir);
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
|
||||
Log::add("Reinstalled {$group}/{$element} from package zip", Log::INFO, 'mokosuiteclient');
|
||||
// Step 2: Create DB records for plugins that have files but no record
|
||||
$db = Factory::getDbo();
|
||||
|
||||
foreach ($expected as $group => $elements)
|
||||
{
|
||||
foreach ($elements as $element)
|
||||
{
|
||||
$pluginDir = JPATH_PLUGINS . '/' . $group . '/' . $element;
|
||||
$manifestFile = $pluginDir . '/' . $element . '.xml';
|
||||
|
||||
if (!is_file($manifestFile))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if record exists
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote($group))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($element))
|
||||
);
|
||||
|
||||
if ((int) $db->loadResult() > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse manifest for name and namespace
|
||||
$xml = @simplexml_load_file($manifestFile);
|
||||
$name = $xml ? (string) ($xml->name ?? '') : '';
|
||||
$namespace = $xml ? (string) ($xml->namespace ?? '') : '';
|
||||
$version = $xml ? (string) ($xml->version ?? '') : '';
|
||||
|
||||
if (empty($name))
|
||||
{
|
||||
$name = 'plg_' . $group . '_' . $element;
|
||||
}
|
||||
|
||||
// Build manifest cache
|
||||
$cache = json_encode([
|
||||
'name' => $name,
|
||||
'type' => 'plugin',
|
||||
'version' => $version,
|
||||
'group' => $group,
|
||||
]);
|
||||
|
||||
// INSERT with all required fields including namespace
|
||||
$columns = 'name, type, element, folder, client_id, enabled, access, protected, locked, params, manifest_cache, custom_data, state, ordering';
|
||||
$values = $db->quote($name) . ', '
|
||||
. $db->quote('plugin') . ', '
|
||||
. $db->quote($element) . ', '
|
||||
. $db->quote($group) . ', '
|
||||
. '0, 1, 1, 0, 0, '
|
||||
. $db->quote('{}') . ', '
|
||||
. $db->quote($cache) . ', '
|
||||
. $db->quote('') . ', 0, 0';
|
||||
|
||||
$sql = "INSERT INTO " . $db->quoteName('#__extensions')
|
||||
. " ({$columns}) VALUES ({$values})";
|
||||
$db->setQuery($sql)->execute();
|
||||
$newId = $db->insertid();
|
||||
|
||||
// Set namespace if column exists
|
||||
if (!empty($namespace))
|
||||
{
|
||||
try
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('namespace') . ' = ' . $db->quote($namespace))
|
||||
->where($db->quoteName('extension_id') . ' = ' . (int) $newId)
|
||||
)->execute();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// namespace column may not exist in this Joomla version
|
||||
}
|
||||
}
|
||||
|
||||
Log::add("Created extension record for {$group}/{$element} (ID {$newId})", Log::INFO, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -754,7 +797,6 @@ class Pkg_MokosuiteclientInstallerScript
|
||||
$db->quote('mod_mokosuiteclient_cpanel'),
|
||||
$db->quote('mokosuiteclientdemo'),
|
||||
$db->quote('mokosuiteclientsync'),
|
||||
$db->quote('mokosuiteclient_tickets'),
|
||||
$db->quote('mokoonyx'),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user