From 4ec90d38f23bec08a5ae755f9e845e29fe7a9079 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 21 Jun 2026 10:57:33 -0500 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20CustomerSatisfactionHelper=20?= =?UTF-8?q?=E2=80=94=20post-service=20NPS,=20technician=20ratings,=20surve?= =?UTF-8?q?y=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Helper/CustomerSatisfactionHelper.php | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/CustomerSatisfactionHelper.php diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/CustomerSatisfactionHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/CustomerSatisfactionHelper.php new file mode 100644 index 0000000..29e78cb --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/CustomerSatisfactionHelper.php @@ -0,0 +1,118 @@ + 5) { + throw new \InvalidArgumentException('Rating must be 1-5.'); + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Prevent duplicate surveys per work order + $db->setQuery($db->getQuery(true) + ->select('id') + ->from('#__mokosuitefield_surveys') + ->where('work_order_id = ' . (int) $workOrderId) + ->where('contact_id = ' . (int) $contactId)); + + if ($db->loadResult()) { + return (object) ['success' => false, 'error' => 'Survey already submitted for this work order']; + } + + $filter = \Joomla\Filter\InputFilter::getInstance(); + + $survey = (object) [ + 'work_order_id' => $workOrderId, + 'contact_id' => $contactId, + 'rating' => $rating, + 'comment' => $comment !== null ? $filter->clean($comment, 'STRING') : null, + 'nps_score' => $rating >= 4 ? 'promoter' : ($rating >= 3 ? 'passive' : 'detractor'), + 'created_at' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitefield_surveys', $survey, 'id'); + + return (object) ['success' => true, 'survey_id' => (int) $survey->id]; + } + + /** + * Get NPS (Net Promoter Score) for a period. + */ + public static function getNps(string $from = '', string $to = ''): object + { + $from = $from ?: date('Y-01-01'); + $to = $to ?: date('Y-m-d'); + + if (!\DateTime::createFromFormat('Y-m-d', $from) || !\DateTime::createFromFormat('Y-m-d', $to)) { + throw new \InvalidArgumentException('Date parameters must be Y-m-d format.'); + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total_responses') + ->select('SUM(CASE WHEN rating >= 4 THEN 1 ELSE 0 END) AS promoters') + ->select('SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END) AS passives') + ->select('SUM(CASE WHEN rating <= 2 THEN 1 ELSE 0 END) AS detractors') + ->select('AVG(rating) AS avg_rating') + ->from('#__mokosuitefield_surveys') + ->where('DATE(created_at) BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to))); + + $stats = $db->loadObject(); + $total = (int) ($stats->total_responses ?? 0); + $promoterPct = $total > 0 ? (int) $stats->promoters / $total * 100 : 0; + $detractorPct = $total > 0 ? (int) $stats->detractors / $total * 100 : 0; + + return (object) [ + 'nps' => round($promoterPct - $detractorPct), + 'total_responses' => $total, + 'promoters' => (int) ($stats->promoters ?? 0), + 'passives' => (int) ($stats->passives ?? 0), + 'detractors' => (int) ($stats->detractors ?? 0), + 'avg_rating' => round((float) ($stats->avg_rating ?? 0), 1), + ]; + } + + /** + * Get technician satisfaction rankings. + */ + public static function getTechnicianRankings(int $limit = 20): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('t.id AS tech_id, cd.name AS tech_name') + ->select('COUNT(s.id) AS survey_count') + ->select('AVG(s.rating) AS avg_rating') + ->select('SUM(CASE WHEN s.rating >= 4 THEN 1 ELSE 0 END) AS five_star_count') + ->from($db->quoteName('#__mokosuitefield_surveys', 's')) + ->join('INNER', $db->quoteName('#__mokosuitefield_work_orders', 'wo') . ' ON wo.id = s.work_order_id') + ->join('INNER', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.tech_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->group('t.id, cd.name') + ->having('COUNT(s.id) >= 3') + ->order('avg_rating DESC'), 0, min(max(1, $limit), 100)); + + $results = $db->loadObjectList() ?: []; + + foreach ($results as &$r) { + $r->avg_rating = round((float) $r->avg_rating, 1); + } + + return $results; + } +} -- 2.52.0 From 54a9f106301002920e84cbab4efdb0bedc536131 Mon Sep 17 00:00:00 2001 From: "gitea-actions[bot]" Date: Sun, 21 Jun 2026 15:57:45 +0000 Subject: [PATCH 2/4] chore(version): auto-bump patch 01.07.01-dev [skip ci] --- .mokogitea/workflows/issue-branch.yml | 2 +- source/pkg_mokosuitefield.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 45b29f8..2c19c81 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.07.00 +# VERSION: 01.07.01 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/source/pkg_mokosuitefield.xml b/source/pkg_mokosuitefield.xml index e79c0f3..4f0e6ae 100644 --- a/source/pkg_mokosuitefield.xml +++ b/source/pkg_mokosuitefield.xml @@ -2,7 +2,7 @@ Package - MokoSuite Field mokosuitefield - 01.07.00 + 01.07.01 2026-06-12 Moko Consulting hello@mokoconsulting.tech -- 2.52.0 From 1bbba0020017402d82dcbe6b55bd572b9ccc27da Mon Sep 17 00:00:00 2001 From: "gitea-actions[bot]" Date: Sun, 21 Jun 2026 15:57:53 +0000 Subject: [PATCH 3/4] chore(version): pre-release bump to 01.07.02-dev [skip ci] --- .mokogitea/workflows/issue-branch.yml | 2 +- source/pkg_mokosuitefield.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 2c19c81..beec2b5 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.07.01 +# VERSION: 01.07.02 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/source/pkg_mokosuitefield.xml b/source/pkg_mokosuitefield.xml index 4f0e6ae..ea4d6fa 100644 --- a/source/pkg_mokosuitefield.xml +++ b/source/pkg_mokosuitefield.xml @@ -2,7 +2,7 @@ Package - MokoSuite Field mokosuitefield - 01.07.01 + 01.07.02 2026-06-12 Moko Consulting hello@mokoconsulting.tech -- 2.52.0 From 181d5f145001da85d7b9e4cfba50541961b5c2f1 Mon Sep 17 00:00:00 2001 From: Jonathan Miller <1+jmiller@noreply.git.mokoconsulting.tech> Date: Sun, 21 Jun 2026 15:01:36 +0000 Subject: [PATCH 4/4] chore: sync issue-branch.yml from Template-Generic [skip ci] --- .mokogitea/workflows/issue-branch.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index beec2b5..75a6963 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.07.02 +# VERSION: 01.00.00 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" -- 2.52.0