From 885e155c084d1fb6087358a99c2b3fdd4f48da3e Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Fri, 10 Jul 2026 16:25:44 -0500 Subject: [PATCH] Revert "Merge branch 'dev' into main" This reverts commit 6fea9d8b1bfccb8873d74dc7b35b86fc3144de42, reversing changes made to d9f60b4c6102dbbe56c67242238683480c22a95f. --- .editorconfig | 34 +- .gitignore | 8 +- .gitmodules | 3 - .mokogitea/ISSUE_TEMPLATE/adr.md | 110 --- .mokogitea/ISSUE_TEMPLATE/bug_report.md | 48 - .mokogitea/ISSUE_TEMPLATE/config.yml | 18 - .mokogitea/ISSUE_TEMPLATE/documentation.md | 52 - .mokogitea/ISSUE_TEMPLATE/feature_request.md | 51 - .mokogitea/ISSUE_TEMPLATE/joomla_issue.md | 87 -- .mokogitea/ISSUE_TEMPLATE/question.md | 82 -- .mokogitea/ISSUE_TEMPLATE/rfc.md | 126 --- .mokogitea/ISSUE_TEMPLATE/security.md | 51 - .mokogitea/ISSUE_TEMPLATE/version.md | 24 - .mokogitea/branch-protection.yml | 251 ----- .mokogitea/workflows/ci-joomla.yml | 903 ------------------ .mokogitea/workflows/issue-branch.yml | 12 +- .mokogitea/workflows/pr-metadata-check.yml | 8 +- CHANGELOG.md | 30 +- CLAUDE.md | 31 - CODE_OF_CONDUCT.md | 55 +- CONTRIBUTING.md | 185 ++-- GOVERNANCE.md | 124 ++- README.md | 45 +- SECURITY.md | 259 +++-- composer.json | 47 +- packages/MokoSuiteERP | 1 - phpstan.neon | 44 +- .../components/com_mokosuitefield/access.xml | 17 - .../com_mokosuitefield/com_mokosuitefield.xml | 46 - .../components/com_mokosuitefield/config.xml | 19 - .../language/en-GB/com_mokosuitefield.ini | 14 - .../language/en-GB/com_mokosuitefield.sys.ini | 15 - .../com_mokosuitefield/services/provider.php | 41 - .../src/Controller/DisplayController.php | 18 - .../src/View/FieldAgreements/HtmlView.php | 24 - .../src/View/FieldChecklists/HtmlView.php | 24 - .../src/View/FieldDashboard/HtmlView.php | 24 - .../src/View/FieldDispatches/HtmlView.php | 24 - .../src/View/FieldEquipment/HtmlView.php | 24 - .../src/View/FieldParts/HtmlView.php | 24 - .../src/View/FieldTechnicians/HtmlView.php | 24 - .../src/View/FieldWorkorders/HtmlView.php | 24 - .../tmpl/fieldagreements/default.php | 13 - .../tmpl/fieldchecklists/default.php | 13 - .../tmpl/fielddashboard/default.php | 13 - .../tmpl/fielddispatches/default.php | 13 - .../tmpl/fieldequipment/default.php | 13 - .../tmpl/fieldparts/default.php | 13 - .../tmpl/fieldtechnicians/default.php | 13 - .../tmpl/fieldworkorders/default.php | 13 - .../com_mokosuitefield/admin/access.xml | 17 +- .../com_mokosuitefield/admin/config.xml | 39 +- .../admin/services/provider.php | 20 +- .../src/Controller/DisplayController.php | 9 +- .../admin/src/Model/DispatchModel.php | 37 + .../admin/src/Model/EquipmentModel.php | 29 + .../admin/src/Model/EstimatesModel.php | 28 + .../admin/src/Model/FieldDashboardModel.php | 17 - .../src/Model/ServiceAgreementsModel.php | 39 + .../admin/src/Model/TechniciansModel.php | 27 + .../admin/src/Model/VehiclesModel.php | 26 + .../admin/src/Model/WorkOrdersModel.php | 62 ++ .../admin/src/View/Checklists/HtmlView.php | 23 - .../admin/src/View/Dashboard/HtmlView.php | 40 + .../admin/src/View/Dispatch/HtmlView.php | 28 + .../admin/src/View/Dispatches/HtmlView.php | 23 - .../admin/src/View/Equipment/HtmlView.php | 27 +- .../src/View/FieldDashboard/HtmlView.php | 23 - .../admin/src/View/Parts/HtmlView.php | 23 - .../admin/src/View/PmAgreements/HtmlView.php | 23 - .../src/View/ServiceAgreements/HtmlView.php | 23 + .../admin/src/View/Technicians/HtmlView.php | 25 +- .../src/View/TruckInventory/HtmlView.php | 23 - .../admin/src/View/Vehicles/HtmlView.php | 23 + .../admin/src/View/WorkOrders/HtmlView.php | 45 +- .../admin/tmpl/checklists/default.php | 1 - .../admin/tmpl/dashboard/default.php | 33 + .../admin/tmpl/dispatch/default.php | 10 + .../admin/tmpl/dispatches/default.php | 1 - .../admin/tmpl/equipment/default.php | 11 +- .../admin/tmpl/fielddashboard/default.php | 1 - .../admin/tmpl/parts/default.php | 1 - .../admin/tmpl/pmagreements/default.php | 1 - .../admin/tmpl/serviceagreements/default.php | 8 + .../admin/tmpl/technicians/default.php | 8 +- .../admin/tmpl/truckinventory/default.php | 1 - .../admin/tmpl/vehicles/default.php | 9 + .../admin/tmpl/workorders/default.php | 35 +- .../Controller/FieldEquipmentController.php | 178 ++++ .../Controller/FieldEstimatesController.php | 150 +++ .../src/Controller/FieldMobileController.php | 252 +++++ .../src/Controller/FieldReportsController.php | 122 +++ .../Controller/FieldSchedulingController.php | 83 ++ .../Controller/FieldWorkOrderController.php | 131 +++ .../com_mokosuitefield/media/css/field.css | 9 + .../com_mokosuitefield/media/js/dispatch.js | 6 + .../com_mokosuitefield/mokosuitefield.xml | 14 - .../site/src/Controller/DisplayController.php | 8 + .../site/src/Service/Router.php | 21 + .../site/src/View/BookService/HtmlView.php | 78 ++ .../site/src/View/CustomerPortal/HtmlView.php | 89 ++ .../site/src/View/EstimateView/HtmlView.php | 90 ++ .../site/src/View/TechMobile/HtmlView.php | 70 ++ .../site/tmpl/bookservice/default.php | 33 + .../site/tmpl/customerportal/default.php | 14 + .../site/tmpl/estimateview/default.php | 96 ++ .../site/tmpl/techmobile/default.php | 56 ++ .../en-GB/plg_system_mokosuitefield.ini | 2 +- .../en-GB/plg_system_mokosuitefield.sys.ini | 2 +- .../mokosuitefield.xml | 64 -- .../services/provider.php | 33 - .../sql/install.mysql.sql | 441 ++++++--- .../sql/uninstall.mysql.sql | 20 +- .../src/Extension/Field.php | 24 - .../src/Helper/CustomerFeedbackHelper.php | 132 +++ .../src/Helper/CustomerSatisfactionHelper.php | 118 +++ .../src/Helper/DispatchHelper.php | 144 +++ .../src/Helper/EquipmentHelper.php | 77 ++ .../src/Helper/EstimateHelper.php | 83 ++ .../src/Helper/GpsTrackingHelper.php | 112 +++ .../src/Helper/InvoiceHelper.php | 151 +++ .../src/Helper/PartsHelper.php | 107 +++ .../src/Helper/RouteHelper.php | 239 +++++ .../src/Helper/SafetyChecklistHelper.php | 147 +++ .../src/Helper/SchedulingHelper.php | 152 +++ .../src/Helper/ServiceAgreementHelper.php | 78 ++ .../src/Helper/TechnicianSkillHelper.php | 97 ++ .../src/Helper/TruckStockHelper.php | 64 ++ .../src/Helper/VehicleHelper.php | 44 + .../src/Helper/WarrantyHelper.php | 102 ++ .../src/Helper/WorkOrderHelper.php | 162 ++++ .../src/Extension/FieldAutomation.php | 144 +++ .../mokosuitefield.xml | 10 - .../services/provider.php | 24 - .../src/Extension/MokoSuiteField.php | 37 - .../src/Extension/MokoSuiteFieldApi.php | 27 + source/pkg_mokosuitefield.xml | 41 +- .../en-GB/plg_system_mokosuitefield.ini | 13 - .../en-GB/plg_system_mokosuitefield.sys.ini | 6 - .../system/mokosuitefield/mokosuitefield.xml | 106 -- .../mokosuitefield/services/provider.php | 36 - .../system/mokosuitefield/sql/install.sql | 170 ---- .../system/mokosuitefield/sql/uninstall.sql | 14 - .../src/Extension/MokoSuiteField.php | 23 - .../en-GB/plg_webservices_mokosuitefield.ini | 6 - .../plg_webservices_mokosuitefield.sys.ini | 6 - .../mokosuitefield/mokosuitefield.xml | 30 - .../mokosuitefield/services/provider.php | 36 - .../src/Extension/MokoSuiteField.php | 50 - source/script.php | 166 ---- source/updates.xml | 9 + 151 files changed, 5129 insertions(+), 3894 deletions(-) delete mode 100644 .mokogitea/ISSUE_TEMPLATE/adr.md delete mode 100644 .mokogitea/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .mokogitea/ISSUE_TEMPLATE/config.yml delete mode 100644 .mokogitea/ISSUE_TEMPLATE/documentation.md delete mode 100644 .mokogitea/ISSUE_TEMPLATE/feature_request.md delete mode 100644 .mokogitea/ISSUE_TEMPLATE/joomla_issue.md delete mode 100644 .mokogitea/ISSUE_TEMPLATE/question.md delete mode 100644 .mokogitea/ISSUE_TEMPLATE/rfc.md delete mode 100644 .mokogitea/ISSUE_TEMPLATE/security.md delete mode 100644 .mokogitea/ISSUE_TEMPLATE/version.md delete mode 100644 .mokogitea/branch-protection.yml delete mode 100644 .mokogitea/workflows/ci-joomla.yml delete mode 100644 CLAUDE.md delete mode 160000 packages/MokoSuiteERP delete mode 100644 source/components/com_mokosuitefield/access.xml delete mode 100644 source/components/com_mokosuitefield/com_mokosuitefield.xml delete mode 100644 source/components/com_mokosuitefield/config.xml delete mode 100644 source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.ini delete mode 100644 source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.sys.ini delete mode 100644 source/components/com_mokosuitefield/services/provider.php delete mode 100644 source/components/com_mokosuitefield/src/Controller/DisplayController.php delete mode 100644 source/components/com_mokosuitefield/src/View/FieldAgreements/HtmlView.php delete mode 100644 source/components/com_mokosuitefield/src/View/FieldChecklists/HtmlView.php delete mode 100644 source/components/com_mokosuitefield/src/View/FieldDashboard/HtmlView.php delete mode 100644 source/components/com_mokosuitefield/src/View/FieldDispatches/HtmlView.php delete mode 100644 source/components/com_mokosuitefield/src/View/FieldEquipment/HtmlView.php delete mode 100644 source/components/com_mokosuitefield/src/View/FieldParts/HtmlView.php delete mode 100644 source/components/com_mokosuitefield/src/View/FieldTechnicians/HtmlView.php delete mode 100644 source/components/com_mokosuitefield/src/View/FieldWorkorders/HtmlView.php delete mode 100644 source/components/com_mokosuitefield/tmpl/fieldagreements/default.php delete mode 100644 source/components/com_mokosuitefield/tmpl/fieldchecklists/default.php delete mode 100644 source/components/com_mokosuitefield/tmpl/fielddashboard/default.php delete mode 100644 source/components/com_mokosuitefield/tmpl/fielddispatches/default.php delete mode 100644 source/components/com_mokosuitefield/tmpl/fieldequipment/default.php delete mode 100644 source/components/com_mokosuitefield/tmpl/fieldparts/default.php delete mode 100644 source/components/com_mokosuitefield/tmpl/fieldtechnicians/default.php delete mode 100644 source/components/com_mokosuitefield/tmpl/fieldworkorders/default.php create mode 100644 source/packages/com_mokosuitefield/admin/src/Model/DispatchModel.php create mode 100644 source/packages/com_mokosuitefield/admin/src/Model/EquipmentModel.php create mode 100644 source/packages/com_mokosuitefield/admin/src/Model/EstimatesModel.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/Model/FieldDashboardModel.php create mode 100644 source/packages/com_mokosuitefield/admin/src/Model/ServiceAgreementsModel.php create mode 100644 source/packages/com_mokosuitefield/admin/src/Model/TechniciansModel.php create mode 100644 source/packages/com_mokosuitefield/admin/src/Model/VehiclesModel.php create mode 100644 source/packages/com_mokosuitefield/admin/src/Model/WorkOrdersModel.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/View/Checklists/HtmlView.php create mode 100644 source/packages/com_mokosuitefield/admin/src/View/Dashboard/HtmlView.php create mode 100644 source/packages/com_mokosuitefield/admin/src/View/Dispatch/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/View/Dispatches/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/View/FieldDashboard/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/View/Parts/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/View/PmAgreements/HtmlView.php create mode 100644 source/packages/com_mokosuitefield/admin/src/View/ServiceAgreements/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/admin/src/View/TruckInventory/HtmlView.php create mode 100644 source/packages/com_mokosuitefield/admin/src/View/Vehicles/HtmlView.php delete mode 100644 source/packages/com_mokosuitefield/admin/tmpl/checklists/default.php create mode 100644 source/packages/com_mokosuitefield/admin/tmpl/dashboard/default.php create mode 100644 source/packages/com_mokosuitefield/admin/tmpl/dispatch/default.php delete mode 100644 source/packages/com_mokosuitefield/admin/tmpl/dispatches/default.php delete mode 100644 source/packages/com_mokosuitefield/admin/tmpl/fielddashboard/default.php delete mode 100644 source/packages/com_mokosuitefield/admin/tmpl/parts/default.php delete mode 100644 source/packages/com_mokosuitefield/admin/tmpl/pmagreements/default.php create mode 100644 source/packages/com_mokosuitefield/admin/tmpl/serviceagreements/default.php delete mode 100644 source/packages/com_mokosuitefield/admin/tmpl/truckinventory/default.php create mode 100644 source/packages/com_mokosuitefield/admin/tmpl/vehicles/default.php create mode 100644 source/packages/com_mokosuitefield/api/src/Controller/FieldEquipmentController.php create mode 100644 source/packages/com_mokosuitefield/api/src/Controller/FieldEstimatesController.php create mode 100644 source/packages/com_mokosuitefield/api/src/Controller/FieldMobileController.php create mode 100644 source/packages/com_mokosuitefield/api/src/Controller/FieldReportsController.php create mode 100644 source/packages/com_mokosuitefield/api/src/Controller/FieldSchedulingController.php create mode 100644 source/packages/com_mokosuitefield/api/src/Controller/FieldWorkOrderController.php create mode 100644 source/packages/com_mokosuitefield/media/css/field.css create mode 100644 source/packages/com_mokosuitefield/media/js/dispatch.js delete mode 100644 source/packages/com_mokosuitefield/mokosuitefield.xml create mode 100644 source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php create mode 100644 source/packages/com_mokosuitefield/site/src/Service/Router.php create mode 100644 source/packages/com_mokosuitefield/site/src/View/BookService/HtmlView.php create mode 100644 source/packages/com_mokosuitefield/site/src/View/CustomerPortal/HtmlView.php create mode 100644 source/packages/com_mokosuitefield/site/src/View/EstimateView/HtmlView.php create mode 100644 source/packages/com_mokosuitefield/site/src/View/TechMobile/HtmlView.php create mode 100644 source/packages/com_mokosuitefield/site/tmpl/bookservice/default.php create mode 100644 source/packages/com_mokosuitefield/site/tmpl/customerportal/default.php create mode 100644 source/packages/com_mokosuitefield/site/tmpl/estimateview/default.php create mode 100644 source/packages/com_mokosuitefield/site/tmpl/techmobile/default.php delete mode 100644 source/packages/plg_system_mokosuitefield/mokosuitefield.xml delete mode 100644 source/packages/plg_system_mokosuitefield/services/provider.php delete mode 100644 source/packages/plg_system_mokosuitefield/src/Extension/Field.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/CustomerFeedbackHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/CustomerSatisfactionHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/EquipmentHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/EstimateHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/GpsTrackingHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/InvoiceHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/PartsHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/SafetyChecklistHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/SchedulingHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/ServiceAgreementHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/TechnicianSkillHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/TruckStockHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/VehicleHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/WarrantyHelper.php create mode 100644 source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php create mode 100644 source/packages/plg_task_mokosuitefield/src/Extension/FieldAutomation.php delete mode 100644 source/packages/plg_webservices_mokosuitefield/mokosuitefield.xml delete mode 100644 source/packages/plg_webservices_mokosuitefield/services/provider.php delete mode 100644 source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteField.php create mode 100644 source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteFieldApi.php delete mode 100644 source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini delete mode 100644 source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini delete mode 100644 source/plugins/system/mokosuitefield/mokosuitefield.xml delete mode 100644 source/plugins/system/mokosuitefield/services/provider.php delete mode 100644 source/plugins/system/mokosuitefield/sql/install.sql delete mode 100644 source/plugins/system/mokosuitefield/sql/uninstall.sql delete mode 100644 source/plugins/system/mokosuitefield/src/Extension/MokoSuiteField.php delete mode 100644 source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.ini delete mode 100644 source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.sys.ini delete mode 100644 source/plugins/webservices/mokosuitefield/mokosuitefield.xml delete mode 100644 source/plugins/webservices/mokosuitefield/services/provider.php delete mode 100644 source/plugins/webservices/mokosuitefield/src/Extension/MokoSuiteField.php delete mode 100644 source/script.php create mode 100644 source/updates.xml diff --git a/.editorconfig b/.editorconfig index 09975f7..e868be9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,31 +1,41 @@ -# EditorConfig https://editorconfig.org +# EditorConfig helps maintain consistent coding styles across different editors and IDEs +# https://editorconfig.org/ + root = true +# Default settings — Tabs preferred, width = 2 spaces [*] -indent_style = tab -indent_size = 2 -end_of_line = lf charset = utf-8 -trim_trailing_whitespace = true +end_of_line = lf insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab +tab_width = 2 +# PowerShell scripts — tabs, 2-space visual width [*.ps1] +indent_style = tab +tab_width = 2 end_of_line = crlf +# Markdown files — keep trailing whitespace for line breaks [*.md] trim_trailing_whitespace = false +# JSON / YAML files — tabs, 2-space visual width [*.{json,yml,yaml}] -indent_style = space -indent_size = 2 - -[*.{mak,Makefile}] indent_style = tab +tab_width = 2 -[*.bat] +# Makefiles — always tabs, default width +[Makefile] +indent_style = tab +tab_width = 2 + +# Windows batch scripts — keep CRLF endings +[*.{bat,cmd}] end_of_line = crlf +# Shell scripts — ensure LF endings [*.sh] end_of_line = lf -indent_style = space -indent_size = 2 diff --git a/.gitignore b/.gitignore index 9d9fe2e..8464492 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,2 @@ -*.min.css -*.min.js -node_modules/ +# Claude Code .claude/ -.mcp.json -TODO.md -*.zip -*.tar.gz diff --git a/.gitmodules b/.gitmodules index f3b894a..1321885 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,6 +4,3 @@ [submodule "packages/MokoSuiteCRM"] path = packages/MokoSuiteCRM url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM.git -[submodule "packages/MokoSuiteERP"] - path = packages/MokoSuiteERP - url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteERP.git diff --git a/.mokogitea/ISSUE_TEMPLATE/adr.md b/.mokogitea/ISSUE_TEMPLATE/adr.md deleted file mode 100644 index eb40760..0000000 --- a/.mokogitea/ISSUE_TEMPLATE/adr.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -name: Architecture Decision Record (ADR) -about: Propose or document an architectural decision -title: '[ADR] ' -labels: 'architecture, decision' -assignees: '' - ---- - - -## ADR Number -ADR-XXXX - -## Status -- [ ] Proposed -- [ ] Accepted -- [ ] Deprecated -- [ ] Superseded by ADR-XXXX - -## Context -Describe the issue or problem that motivates this decision. - -## Decision -State the architecture decision and provide rationale. - -## Consequences -### Positive -- List positive consequences - -### Negative -- List negative consequences or trade-offs - -### Neutral -- List neutral aspects - -## Alternatives Considered -### Alternative 1 -- Description -- Pros -- Cons -- Why not chosen - -### Alternative 2 -- Description -- Pros -- Cons -- Why not chosen - -## Implementation Plan -1. Step 1 -2. Step 2 -3. Step 3 - -## Stakeholders -- **Decision Makers**: @user1, @user2 -- **Consulted**: @user3, @user4 -- **Informed**: team-name - -## Technical Details -### Architecture Diagram -``` -[Add diagram or link] -``` - -### Dependencies -- Dependency 1 -- Dependency 2 - -### Impact Analysis -- **Performance**: [Impact description] -- **Security**: [Impact description] -- **Scalability**: [Impact description] -- **Maintainability**: [Impact description] - -## Testing Strategy -- [ ] Unit tests -- [ ] Integration tests -- [ ] Performance tests -- [ ] Security tests - -## Documentation -- [ ] Architecture documentation updated -- [ ] API documentation updated -- [ ] Developer guide updated -- [ ] Runbook created - -## Migration Path -Describe how to migrate from current state to new architecture. - -## Rollback Plan -Describe how to rollback if issues occur. - -## Timeline -- **Proposal Date**: -- **Decision Date**: -- **Implementation Start**: -- **Expected Completion**: - -## References -- Related ADRs: -- External resources: -- RFCs: - -## Review Checklist -- [ ] Aligns with enterprise architecture principles -- [ ] Security implications reviewed -- [ ] Performance implications reviewed -- [ ] Cost implications reviewed -- [ ] Compliance requirements met -- [ ] Team consensus achieved diff --git a/.mokogitea/ISSUE_TEMPLATE/bug_report.md b/.mokogitea/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 38a16a7..0000000 --- a/.mokogitea/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: Bug Report -about: Report a bug or issue with the project -title: '[BUG] ' -labels: 'bug' -assignees: '' - ---- - - -## Bug Description -A clear and concise description of what the bug is. - -## Steps to Reproduce -1. Go to '...' -2. Click on '...' -3. Scroll down to '...' -4. See error - -## Expected Behavior -A clear and concise description of what you expected to happen. - -## Actual Behavior -A clear and concise description of what actually happened. - -## Screenshots -If applicable, add screenshots to help explain your problem. - -## Environment -- **Project**: [e.g., MokoDoliTools, moko-cassiopeia] -- **Version**: [e.g., 1.2.3] -- **Platform**: [e.g., Dolibarr 18.0, Joomla 5.0] -- **PHP Version**: [e.g., 8.1] -- **Database**: [e.g., MySQL 8.0, PostgreSQL 14] -- **Browser** (if applicable): [e.g., Chrome 120, Firefox 121] -- **OS**: [e.g., Ubuntu 22.04, Windows 11] - -## Additional Context -Add any other context about the problem here. - -## Possible Solution -If you have suggestions on how to fix the issue, please describe them here. - -## Checklist -- [ ] I have searched for similar issues before creating this one -- [ ] I have provided all the requested information -- [ ] I have tested this on the latest stable version -- [ ] I have checked the documentation and couldn't find a solution diff --git a/.mokogitea/ISSUE_TEMPLATE/config.yml b/.mokogitea/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index d4d49ec..0000000 --- a/.mokogitea/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,18 +0,0 @@ ---- -blank_issues_enabled: true -contact_links: - - name: 💼 Enterprise Support - url: https://mokoconsulting.tech/enterprise - about: Enterprise-level support and consultation services - - name: 💬 Ask a Question - url: https://mokoconsulting.tech/ - about: Get help or ask questions through our website - - name: 📚 MokoStandards Documentation - url: https://git.mokoconsulting.tech/MokoConsulting/moko-platform - about: View our coding standards and best practices - - name: 🔒 Report a Security Vulnerability - url: https://git.mokoconsulting.tech/mokoconsulting-tech/.github-private/security/advisories/new - about: Report security vulnerabilities privately (for critical issues) - - name: 💡 Community Discussions - url: https://github.com/orgs/mokoconsulting-tech/discussions - about: Join community discussions and Q&A diff --git a/.mokogitea/ISSUE_TEMPLATE/documentation.md b/.mokogitea/ISSUE_TEMPLATE/documentation.md deleted file mode 100644 index ed4dabc..0000000 --- a/.mokogitea/ISSUE_TEMPLATE/documentation.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: Documentation Issue -about: Report an issue with documentation -title: '[DOCS] ' -labels: 'documentation' -assignees: '' - ---- - - -## Documentation Issue - -**Location**: - - -## Issue Type - -- [ ] Typo or grammar error -- [ ] Outdated information -- [ ] Missing documentation -- [ ] Unclear explanation -- [ ] Broken links -- [ ] Missing examples -- [ ] Other (specify below) - -## Description - - -## Current Content - -``` -Current text here -``` - -## Suggested Improvement - -``` -Suggested text here -``` - -## Additional Context - - -## Standards Alignment -- [ ] Follows MokoStandards documentation guidelines -- [ ] Uses en_US/en_GB localization -- [ ] Includes proper SPDX headers where applicable - -## Checklist -- [ ] I have searched for similar documentation issues -- [ ] I have provided a clear description -- [ ] I have suggested an improvement (if applicable) diff --git a/.mokogitea/ISSUE_TEMPLATE/feature_request.md b/.mokogitea/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 7b76dc9..0000000 --- a/.mokogitea/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: Feature Request -about: Suggest a new feature or enhancement -title: '[FEATURE] ' -labels: 'enhancement' -assignees: '' - ---- - - -## Feature Description -A clear and concise description of the feature you'd like to see. - -## Problem or Use Case -Describe the problem this feature would solve or the use case it addresses. -Ex. I'm always frustrated when [...] - -## Proposed Solution -A clear and concise description of what you want to happen. - -## Alternative Solutions -A clear and concise description of any alternative solutions or features you've considered. - -## Benefits -Describe how this feature would benefit users: -- Who would use this feature? -- What problems does it solve? -- What value does it add? - -## Implementation Details (Optional) -If you have ideas about how this could be implemented, share them here: -- Technical approach -- Files/components that might need changes -- Any concerns or challenges you foresee - -## Additional Context -Add any other context, mockups, or screenshots about the feature request here. - -## Relevant Standards -Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)? -- [ ] Accessibility (WCAG 2.1 AA) -- [ ] Localization (en_US/en_GB) -- [ ] Security best practices -- [ ] Code quality standards -- [ ] Other: [specify] - -## Checklist -- [ ] I have searched for similar feature requests before creating this one -- [ ] I have clearly described the use case and benefits -- [ ] I have considered alternative solutions -- [ ] This feature aligns with the project's goals and scope diff --git a/.mokogitea/ISSUE_TEMPLATE/joomla_issue.md b/.mokogitea/ISSUE_TEMPLATE/joomla_issue.md deleted file mode 100644 index d808f79..0000000 --- a/.mokogitea/ISSUE_TEMPLATE/joomla_issue.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -name: Joomla Extension Issue -about: Report an issue with a Joomla extension -title: '[JOOMLA] ' -labels: 'joomla' -assignees: '' - ---- - - -## Issue Type -- [ ] Component Issue -- [ ] Module Issue -- [ ] Plugin Issue -- [ ] Template Issue - -## Extension Details -- **Extension Name**: [e.g., moko-cassiopeia] -- **Extension Version**: [e.g., 1.2.3] -- **Extension Type**: [Component / Module / Plugin / Template] - -## Joomla Environment -- **Joomla Version**: [e.g., 4.4.0, 5.0.0] -- **PHP Version**: [e.g., 8.1.0] -- **Database**: [MySQL / PostgreSQL / MariaDB] -- **Database Version**: [e.g., 8.0] -- **Server**: [Apache / Nginx / IIS] -- **Hosting**: [Shared / VPS / Dedicated / Cloud] - -## Issue Description -Provide a clear and detailed description of the issue. - -## Steps to Reproduce -1. Go to '...' -2. Click on '...' -3. Configure '...' -4. See error - -## Expected Behavior -What you expected to happen. - -## Actual Behavior -What actually happened. - -## Error Messages -``` -# Paste any error messages from Joomla error logs -# Location: administrator/logs/error.php -``` - -## Browser Console Errors -```javascript -// Paste any JavaScript console errors (F12 in browser) -``` - -## Screenshots -Add screenshots to help explain the issue. - -## Configuration -```ini -# Paste extension configuration (sanitize sensitive data) -``` - -## Installed Extensions -List other installed extensions that might conflict: -- Extension 1 (version) -- Extension 2 (version) - -## Template Overrides -- [ ] Using template overrides -- [ ] Custom CSS -- [ ] Custom JavaScript - -## Additional Context -- **Multilingual Site**: [Yes / No] -- **Cache Enabled**: [Yes / No] -- **Debug Mode**: [Yes / No] -- **SEF URLs**: [Yes / No] - -## Checklist -- [ ] I have cleared Joomla cache -- [ ] I have disabled other extensions to test for conflicts -- [ ] I have checked Joomla error logs -- [ ] I have tested with a default Joomla template -- [ ] I have checked browser console for JavaScript errors -- [ ] I have searched for similar issues -- [ ] I am using a supported Joomla version diff --git a/.mokogitea/ISSUE_TEMPLATE/question.md b/.mokogitea/ISSUE_TEMPLATE/question.md deleted file mode 100644 index 3175013..0000000 --- a/.mokogitea/ISSUE_TEMPLATE/question.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -name: Question -about: Ask a question about usage, features, or best practices -title: '[QUESTION] ' -labels: ['question'] -assignees: ['jmiller'] ---- - - -## Question - -**Your question:** - - -## Context - -**What are you trying to accomplish?** - - -**What have you already tried?** - - -**Category**: -- [ ] Script usage -- [ ] Configuration -- [ ] Workflow setup -- [ ] Documentation interpretation -- [ ] Best practices -- [ ] Integration -- [ ] Other: __________ - -## Environment (if relevant) - -**Your setup**: -- Operating System: -- Version: - -## What You've Researched - -**Documentation reviewed**: -- [ ] README.md -- [ ] Project documentation -- [ ] Other (specify): __________ - -**Similar issues/questions found**: -- # -- # - -## Expected Outcome - -**What result are you hoping for?** - - -## Code/Configuration Samples - -**Relevant code or configuration** (if applicable): - -```bash -# Your code here -``` - -## Additional Context - -**Any other relevant information:** - - -**Screenshots** (if helpful): - - -## Urgency - -- [ ] Urgent (blocking work) -- [ ] Normal (can work on other things meanwhile) -- [ ] Low priority (just curious) - -## Checklist - -- [ ] I have searched existing issues and discussions -- [ ] I have reviewed relevant documentation -- [ ] I have provided sufficient context -- [ ] I have included code/configuration samples if relevant -- [ ] This is a genuine question (not a bug report or feature request) diff --git a/.mokogitea/ISSUE_TEMPLATE/rfc.md b/.mokogitea/ISSUE_TEMPLATE/rfc.md deleted file mode 100644 index 6f09af7..0000000 --- a/.mokogitea/ISSUE_TEMPLATE/rfc.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -name: Request for Comments (RFC) -about: Propose a significant change for community discussion -title: '[RFC] ' -labels: 'rfc, discussion' -assignees: '' - ---- - - -## RFC Summary -One-paragraph summary of the proposal. - -## Motivation -Why are we doing this? What use cases does it support? What is the expected outcome? - -## Detailed Design -### Overview -Provide a detailed explanation of the proposed change. - -### API Changes (if applicable) -```php -// Before -function oldApi($param1) { } - -// After -function newApi($param1, $param2) { } -``` - -### User Experience Changes -Describe how users will interact with this change. - -### Implementation Approach -High-level implementation strategy. - -## Drawbacks -Why should we *not* do this? - -## Alternatives -What other designs have been considered? What is the impact of not doing this? - -### Alternative 1 -- Description -- Trade-offs - -### Alternative 2 -- Description -- Trade-offs - -## Adoption Strategy -How will existing users adopt this? Is this a breaking change? - -### Migration Guide -```bash -# Steps to migrate -``` - -### Deprecation Timeline -- **Announcement**: -- **Deprecation**: -- **Removal**: - -## Unresolved Questions -- Question 1 -- Question 2 - -## Future Possibilities -What future work does this enable? - -## Impact Assessment -### Performance -Expected performance impact. - -### Security -Security considerations and implications. - -### Compatibility -- **Backward Compatible**: [Yes / No] -- **Breaking Changes**: [List] - -### Maintenance -Long-term maintenance considerations. - -## Community Input -### Stakeholders -- [ ] Core team -- [ ] Module developers -- [ ] End users -- [ ] Enterprise customers - -### Feedback Period -**Duration**: [e.g., 2 weeks] -**Deadline**: [date] - -## Implementation Timeline -### Phase 1: Design -- [ ] RFC discussion -- [ ] Design finalization -- [ ] Approval - -### Phase 2: Implementation -- [ ] Core implementation -- [ ] Tests -- [ ] Documentation - -### Phase 3: Release -- [ ] Beta release -- [ ] Feedback collection -- [ ] Stable release - -## Success Metrics -How will we measure success? -- Metric 1 -- Metric 2 - -## References -- Related RFCs: -- External documentation: -- Prior art: - -## Open Questions for Community -1. Question 1? -2. Question 2? - ---- -**Note**: This RFC is open for community discussion. Please provide feedback in the comments below. diff --git a/.mokogitea/ISSUE_TEMPLATE/security.md b/.mokogitea/ISSUE_TEMPLATE/security.md deleted file mode 100644 index f57b284..0000000 --- a/.mokogitea/ISSUE_TEMPLATE/security.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: Security Vulnerability Report -about: Report a security vulnerability (use only for non-critical issues) -title: '[SECURITY] ' -labels: 'security' -assignees: '' - ---- - - -## ⚠️ IMPORTANT: Private Disclosure Required - -**For critical security vulnerabilities, DO NOT use this template.** -Follow the process in [SECURITY.md](../SECURITY.md) for responsible disclosure. - -Use this template only for: -- Security improvements -- Non-critical security suggestions -- Security documentation updates - ---- - -## Security Issue - -**Severity**: - - -## Description - - -## Affected Components - - -## Suggested Mitigation - - -## Standards Reference -Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)? -- [ ] SPDX license identifiers -- [ ] Secret management -- [ ] Dependency security -- [ ] Access control -- [ ] Other: [specify] - -## Additional Context - - -## Checklist -- [ ] This is NOT a critical vulnerability requiring private disclosure -- [ ] I have reviewed the SECURITY.md policy -- [ ] I have provided sufficient detail for evaluation diff --git a/.mokogitea/ISSUE_TEMPLATE/version.md b/.mokogitea/ISSUE_TEMPLATE/version.md deleted file mode 100644 index 6328421..0000000 --- a/.mokogitea/ISSUE_TEMPLATE/version.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: Version Bump -about: Request or track a version change -title: '[VERSION] ' -labels: 'version, type: version' -assignees: 'jmiller' ---- - -## Version Change - -**Current version**: -**Requested version**: -**Change type**: - -## Reason - - - -## Checklist - -- [ ] README.md `VERSION:` field updated -- [ ] CHANGELOG.md entry added -- [ ] Module descriptor version updated (Dolibarr: `$this->version`, Joomla: ``) -- [ ] All file headers will be auto-propagated by `sync-version-on-merge` workflow diff --git a/.mokogitea/branch-protection.yml b/.mokogitea/branch-protection.yml deleted file mode 100644 index 2dff8b9..0000000 --- a/.mokogitea/branch-protection.yml +++ /dev/null @@ -1,251 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# SPDX-License-Identifier: GPL-3.0-or-later -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: moko-platform.Automation -# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform -# PATH: /.gitea/workflows/branch-protection.yml -# BRIEF: Apply standardised branch protection rules to all governed repositories -# -# +========================================================================+ -# | BRANCH PROTECTION SETUP | -# +========================================================================+ -# | | -# | Applies protection rules for: main, dev, rc, beta, alpha | -# | | -# | main — Require PR, block rejected reviews, no force push | -# | dev — Allow push, no force push, no delete | -# | rc — Allow push, no force push, no delete | -# | beta — Allow push, no force push, no delete | -# | alpha — Allow push, no force push, no delete | -# | | -# | jmiller has override authority on all branches. | -# | | -# +========================================================================+ - -name: Branch Protection Setup - -on: - schedule: - - cron: '0 2 * * 1' # Weekly Monday 02:00 UTC - workflow_dispatch: - inputs: - dry_run: - description: 'Preview mode (no changes)' - required: false - type: boolean - default: false - repos: - description: 'Comma-separated repo names (empty = all governed repos)' - required: false - type: string - default: '' - -env: - GITEA_URL: https://git.mokoconsulting.tech - GITEA_ORG: MokoConsulting - -permissions: - contents: read - -jobs: - protect: - name: Apply Branch Protection Rules - runs-on: ubuntu-latest - - steps: - - name: Determine target repos - id: repos - env: - GA_TOKEN: ${{ secrets.GA_TOKEN }} - run: | - API="${GITEA_URL}/api/v1" - - # Platform/standards/infra repos to exclude - EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting" - EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate" - - if [ -n "${{ inputs.repos }}" ]; then - # User-specified repos - REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ') - else - # Fetch all org repos - PAGE=1 - REPOS="" - while true; do - BATCH=$(curl -sS \ - -H "Authorization: token ${GA_TOKEN}" \ - "${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \ - | jq -r '.[].name // empty') - [ -z "$BATCH" ] && break - REPOS="$REPOS $BATCH" - PAGE=$((PAGE + 1)) - done - - # Filter out excluded repos - FILTERED="" - for REPO in $REPOS; do - SKIP=false - for EX in $EXCLUDE; do - if [ "$REPO" = "$EX" ]; then - SKIP=true - break - fi - done - if [ "$SKIP" = "false" ]; then - FILTERED="$FILTERED $REPO" - fi - done - REPOS="$FILTERED" - fi - - echo "repos=$REPOS" >> "$GITHUB_OUTPUT" - COUNT=$(echo "$REPOS" | wc -w) - echo "📋 Target repos (${COUNT}): $REPOS" - - - name: Apply protection rules - env: - GA_TOKEN: ${{ secrets.GA_TOKEN }} - DRY_RUN: ${{ inputs.dry_run || 'false' }} - run: | - API="${GITEA_URL}/api/v1" - REPOS="${{ steps.repos.outputs.repos }}" - - SUCCESS=0 - FAILED=0 - SKIPPED=0 - - # ── Rule definitions ────────────────────────────────────── - # Only the CI bot (jmiller token) can push directly. - # All human contributors must use PRs. - # Force push disabled on all branches. - - RULE_MAIN='{ - "rule_name": "main", - "enable_push": true, - "enable_push_whitelist": true, - "push_whitelist_usernames": ["jmiller"], - "enable_force_push": false, - "enable_force_push_allowlist": false, - "force_push_allowlist_usernames": [], - "enable_merge_whitelist": false, - "required_approvals": 0, - "dismiss_stale_approvals": true, - "block_on_rejected_reviews": true, - "block_on_outdated_branch": false, - "priority": 1 - }' - - RULE_DEV='{ - "rule_name": "dev", - "enable_push": true, - "enable_push_whitelist": true, - "push_whitelist_usernames": ["jmiller"], - "enable_force_push": false, - "enable_force_push_allowlist": false, - "force_push_allowlist_usernames": [], - "enable_merge_whitelist": false, - "required_approvals": 0, - "block_on_rejected_reviews": false, - "priority": 2 - }' - - RULE_RC='{ - "rule_name": "rc", - "enable_push": true, - "enable_push_whitelist": true, - "push_whitelist_usernames": ["jmiller"], - "enable_force_push": false, - "enable_force_push_allowlist": false, - "force_push_allowlist_usernames": [], - "enable_merge_whitelist": false, - "required_approvals": 0, - "block_on_rejected_reviews": false, - "priority": 3 - }' - - RULE_BETA='{ - "rule_name": "beta", - "enable_push": true, - "enable_push_whitelist": true, - "push_whitelist_usernames": ["jmiller"], - "enable_force_push": false, - "enable_force_push_allowlist": false, - "force_push_allowlist_usernames": [], - "enable_merge_whitelist": false, - "required_approvals": 0, - "block_on_rejected_reviews": false, - "priority": 4 - }' - - RULE_ALPHA='{ - "rule_name": "alpha", - "enable_push": true, - "enable_push_whitelist": true, - "push_whitelist_usernames": ["jmiller"], - "enable_force_push": false, - "enable_force_push_allowlist": false, - "force_push_allowlist_usernames": [], - "enable_merge_whitelist": false, - "required_approvals": 0, - "block_on_rejected_reviews": false, - "priority": 5 - }' - - RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA") - RULE_NAMES=("main" "dev" "rc" "beta" "alpha") - - # ── Apply rules to each repo ────────────────────────────── - for REPO in $REPOS; do - echo "" - echo "═══ ${REPO} ═══" - - for i in "${!RULES[@]}"; do - RULE="${RULES[$i]}" - NAME="${RULE_NAMES[$i]}" - - if [ "$DRY_RUN" = "true" ]; then - echo " [DRY RUN] Would apply rule: ${NAME}" - SKIPPED=$((SKIPPED + 1)) - continue - fi - - # Delete existing rule if present (idempotent recreate) - ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g') - curl -sS -o /dev/null -w "" \ - -X DELETE \ - -H "Authorization: token ${GA_TOKEN}" \ - "${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true - - # Create rule - RESPONSE=$(curl -sS -w "\n%{http_code}" \ - -X POST \ - -H "Authorization: token ${GA_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "$RULE" \ - "${API}/repos/${GITEA_ORG}/${REPO}/branch_protections") - - HTTP=$(echo "$RESPONSE" | tail -1) - BODY=$(echo "$RESPONSE" | sed '$d') - - if [ "$HTTP" = "201" ]; then - echo " ✅ ${NAME}" - SUCCESS=$((SUCCESS + 1)) - else - echo " ❌ ${NAME} (HTTP ${HTTP}): $(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)" - FAILED=$((FAILED + 1)) - fi - done - done - - # ── Summary ─────────────────────────────────────────────── - echo "" - echo "════════════════════════════════════════" - echo " ✅ Success: ${SUCCESS}" - echo " ❌ Failed: ${FAILED}" - echo " ⏭️ Skipped: ${SKIPPED}" - echo "════════════════════════════════════════" - - if [ "$FAILED" -gt 0 ]; then - echo "::warning::${FAILED} rule(s) failed to apply" - fi diff --git a/.mokogitea/workflows/ci-joomla.yml b/.mokogitea/workflows/ci-joomla.yml deleted file mode 100644 index 727f661..0000000 --- a/.mokogitea/workflows/ci-joomla.yml +++ /dev/null @@ -1,903 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# -# This file is part of a Moko Consulting project. -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow.Template -# INGROUP: MokoStandards.CI -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API -# PATH: /templates/workflows/joomla/ci-joomla.yml.template -# VERSION: 04.06.00 -# BRIEF: CI workflow for Joomla extensions — lint, validate, test - -name: "Joomla: Extension CI" - -on: - pull_request: - branches: - - main - - 'dev/**' - workflow_dispatch: - -permissions: - contents: read - pull-requests: write - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - lint-and-validate: - name: Lint & Validate - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup PHP - run: | - if ! command -v php &> /dev/null; then - sudo apt-get update -qq - sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 - fi - php -v && composer --version - - - name: Setup mokocli tools - env: - MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }} - MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }} - run: | - if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then - echo "mokocli already available on runner — skipping clone" - else - git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \ - /tmp/mokocli 2>/dev/null || echo "mokocli clone skipped — continuing without it" - fi - - - name: Install dependencies - env: - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}' - run: | - if [ -f "composer.json" ]; then - composer install \ - --no-interaction \ - --prefer-dist \ - --optimize-autoloader - else - echo "No composer.json found — skipping dependency install" - fi - - - name: PHP syntax check - run: | - ERRORS=0 - for DIR in src/ htdocs/; do - if [ -d "$DIR" ]; then - FOUND=1 - while IFS= read -r -d '' FILE; do - OUTPUT=$(php -l "$FILE" 2>&1) - if echo "$OUTPUT" | grep -q "Parse error"; then - echo "::error file=${FILE}::${OUTPUT}" - ERRORS=$((ERRORS + 1)) - fi - done < <(find "$DIR" -name "*.php" -print0) - fi - done - echo "### PHP Syntax Check" >> $GITHUB_STEP_SUMMARY - if [ "${ERRORS}" -gt 0 ]; then - echo "**${ERRORS} syntax error(s) found.**" >> $GITHUB_STEP_SUMMARY - exit 1 - else - echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY - fi - - - name: XML manifest validation - run: | - echo "### XML Manifest Validation" >> $GITHUB_STEP_SUMMARY - ERRORS=0 - - # Find the extension manifest (XML with /dev/null; then - MANIFEST="$XML_FILE" - break - fi - done - - if [ -z "$MANIFEST" ]; then - echo "No Joomla extension manifest found (XML file with \`> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - echo "Manifest found: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY - - # Validate well-formed XML - php -r " - \$xml = @simplexml_load_file('$MANIFEST'); - if (\$xml === false) { - echo 'INVALID'; - exit(1); - } - echo 'VALID'; - " > /tmp/xml_result 2>&1 - XML_RESULT=$(cat /tmp/xml_result) - if [ "$XML_RESULT" != "VALID" ]; then - echo "Manifest is not well-formed XML." >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY - fi - - # Check required tags: name, version, author - for TAG in name version author; do - if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then - echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY - fi - done - - # Namespace is required for components/plugins but not packages - EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1) - if [ "$EXT_TYPE" != "package" ]; then - if ! grep -q "/dev/null; then - echo "Missing required tag: \`\` (required for Joomla 5+ ${EXT_TYPE} extensions)" >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - echo "Found required tag: \`\`" >> $GITHUB_STEP_SUMMARY - fi - else - echo "Package extension — \`\` not required." >> $GITHUB_STEP_SUMMARY - fi - fi - - if [ "${ERRORS}" -gt 0 ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "**${ERRORS} manifest issue(s) found.**" >> $GITHUB_STEP_SUMMARY - exit 1 - else - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY - fi - - - name: Check language files referenced in manifest - run: | - echo "### Language File Check" >> $GITHUB_STEP_SUMMARY - ERRORS=0 - - MANIFEST="" - for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do - if grep -q "/dev/null; then - MANIFEST="$XML_FILE" - break - fi - done - - if [ -n "$MANIFEST" ]; then - # Extract language file references from manifest - LANG_FILES=$(grep -oP 'language\s+tag="[^"]*"[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true) - if [ -z "$LANG_FILES" ]; then - echo "No language file references found in manifest — skipping." >> $GITHUB_STEP_SUMMARY - else - while IFS= read -r LANG_FILE; do - LANG_FILE=$(echo "$LANG_FILE" | xargs) - if [ -z "$LANG_FILE" ]; then - continue - fi - # Check in common locations - FOUND=0 - for BASE in "." "src" "htdocs"; do - if [ -f "${BASE}/${LANG_FILE}" ]; then - FOUND=1 - break - fi - done - if [ "$FOUND" -eq 0 ]; then - echo "Missing language file: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - echo "Language file present: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY - fi - done <<< "$LANG_FILES" - fi - else - echo "No manifest found — skipping language check." >> $GITHUB_STEP_SUMMARY - fi - - if [ "${ERRORS}" -gt 0 ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "**${ERRORS} missing language file(s).**" >> $GITHUB_STEP_SUMMARY - exit 1 - else - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Language file check passed.**" >> $GITHUB_STEP_SUMMARY - fi - - - name: Check index.html files in directories - run: | - echo "### Index.html Check" >> $GITHUB_STEP_SUMMARY - MISSING=0 - CHECKED=0 - - for DIR in src/ htdocs/; do - if [ -d "$DIR" ]; then - while IFS= read -r -d '' SUBDIR; do - CHECKED=$((CHECKED + 1)) - if [ ! -f "${SUBDIR}/index.html" ]; then - echo "Missing index.html in: \`${SUBDIR}\`" >> $GITHUB_STEP_SUMMARY - MISSING=$((MISSING + 1)) - fi - done < <(find "$DIR" -type d -print0) - fi - done - - if [ "${CHECKED}" -eq 0 ]; then - echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY - elif [ "${MISSING}" -gt 0 ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $GITHUB_STEP_SUMMARY - exit 1 - else - echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY - fi - - - name: Check config.xml and access.xml for components - run: | - echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY - ERRORS=0 - - # Find all component manifests (XML with type="component") - COMP_MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l ']*type="component"' {} ; 2>/dev/null || true) - - if [ -z "$COMP_MANIFESTS" ]; then - echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY - else - for MANIFEST in $COMP_MANIFESTS; do - COMP_DIR=$(dirname "$MANIFEST") - COMP_NAME=$(basename "$COMP_DIR") - echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY - - # Check access.xml exists - ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1) - if [ -z "$ACCESS_FILE" ]; then - echo "- Missing `access.xml` — ACL permissions will not work." >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - if command -v php &> /dev/null; then - if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then - echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - for ACTION in core.admin core.manage; do - if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then - echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - fi - done - echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY - fi - fi - fi - - # Check config.xml exists - CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1) - if [ -z "$CONFIG_FILE" ]; then - echo "- Missing `config.xml` — component Options page will be empty." >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - if command -v php &> /dev/null; then - if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then - echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY - fi - fi - fi - done - fi - - echo "" >> $GITHUB_STEP_SUMMARY - if [ "${ERRORS}" -gt 0 ]; then - echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY - exit 1 - else - echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY - fi - - - name: SQL schema validation - run: | - echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY - ERRORS=0 - - # Find SQL files in source/htdocs - SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) - if [ -z "$SQL_FILES" ]; then - echo "No SQL files found — skipping." >> $GITHUB_STEP_SUMMARY - else - echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY - - for FILE in $SQL_FILES; do - # Basic syntax check: balanced parentheses, no empty files - SIZE=$(wc -c < "$FILE" | tr -d ' ') - if [ "$SIZE" -eq 0 ]; then - echo "- Empty SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - continue - fi - - # Check for common SQL errors - if grep -qP '^\s*$' "$FILE" && [ "$SIZE" -lt 5 ]; then - echo "- Whitespace-only SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - continue - fi - - echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY - done - - # Check update SQL files follow version numbering pattern - UPDATE_DIR=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null | head -1) - if [ -n "$UPDATE_DIR" ]; then - BAD_NAMES=0 - for UFILE in "$UPDATE_DIR"/*.sql; do - [ ! -f "$UFILE" ] && continue - BASENAME=$(basename "$UFILE" .sql) - if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then - echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY - BAD_NAMES=$((BAD_NAMES + 1)) - fi - done - if [ "$BAD_NAMES" -gt 0 ]; then - ERRORS=$((ERRORS + BAD_NAMES)) - fi - fi - fi - - echo "" >> $GITHUB_STEP_SUMMARY - if [ "${ERRORS}" -gt 0 ]; then - echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY - exit 1 - else - echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY - fi - - - name: Manifest file references check - run: | - echo "### Manifest File References" >> $GITHUB_STEP_SUMMARY - ERRORS=0 - - MANIFEST="" - for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do - if grep -q "/dev/null; then - MANIFEST="$XML_FILE" - break - fi - done - - if [ -z "$MANIFEST" ]; then - echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY - else - MANIFEST_DIR=$(dirname "$MANIFEST") - - # Check references - FILENAMES=$(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null || true) - for F in $FILENAMES; do - if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then - echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - fi - done - - # Check references - FOLDERS=$(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null || true) - for F in $FOLDERS; do - if [ ! -d "${MANIFEST_DIR}/${F}" ]; then - echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - fi - done - - # Check references in package manifests (ZIP files won't exist in source) - EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1) - if [ "$EXT_TYPE" != "package" ]; then - FILES=$(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null || true) - for F in $FILES; do - if [ ! -f "${MANIFEST_DIR}/${F}" ]; then - echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - fi - done - fi - fi - - echo "" >> $GITHUB_STEP_SUMMARY - if [ "${ERRORS}" -gt 0 ]; then - echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY - exit 1 - else - echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY - fi - - - name: Form XML validation - run: | - echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY - ERRORS=0 - - FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) - if [ -z "$FORM_FILES" ]; then - echo "No form XML files found — skipping." >> $GITHUB_STEP_SUMMARY - else - echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY - for FILE in $FORM_FILES; do - if command -v php &> /dev/null; then - if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then - echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - # Check for valid Joomla form structure - if ! grep -qE '/dev/null; then - echo "- \`${FILE}\`: no \`
\`, \`\`, or \`
\` elements found" >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY - fi - fi - fi - done - fi - - echo "" >> $GITHUB_STEP_SUMMARY - if [ "${ERRORS}" -gt 0 ]; then - echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY - exit 1 - else - echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY - fi - - - name: Deprecated Joomla API check - continue-on-error: true - run: | - echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY - WARNINGS=0 - - SRC_DIR="" - for DIR in source/ src/ htdocs/; do - [ -d "$DIR" ] && SRC_DIR="$DIR" && break - done - - if [ -z "$SRC_DIR" ]; then - echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY - else - # Joomla 3/4 deprecated patterns that break in Joomla 6 - PATTERNS=( - 'JFactory::' - 'JText::' - 'JHtml::' - 'JRoute::' - 'JUri::' - 'JLog::' - 'JTable::' - 'JInput' - 'CMSFactory::\$application' - 'JApplicationCms' - ) - - for PATTERN in "${PATTERNS[@]}"; do - HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true) - if [ -n "$HITS" ]; then - COUNT=$(echo "$HITS" | wc -l) - echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY - WARNINGS=$((WARNINGS + COUNT)) - fi - done - - echo "" >> $GITHUB_STEP_SUMMARY - if [ "$WARNINGS" -gt 0 ]; then - echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY - else - echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY - fi - fi - - - name: Template output escaping check - continue-on-error: true - run: | - echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY - WARNINGS=0 - - TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) - if [ -z "$TMPL_FILES" ]; then - echo "No template files found — skipping." >> $GITHUB_STEP_SUMMARY - else - echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY - - for FILE in $TMPL_FILES; do - # Check for unescaped output: or echo $var without escape() - UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true) - if [ -n "$UNESCAPED" ]; then - HITS=$(echo "$UNESCAPED" | wc -l) - echo "- \`${FILE}\`: ${HITS} unescaped \`\` output(s) — use \`escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY - WARNINGS=$((WARNINGS + HITS)) - fi - - # Check for echo without escaping in template context - RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true) - if [ -n "$RAW_ECHO" ]; then - HITS=$(echo "$RAW_ECHO" | wc -l) - echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` — consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY - WARNINGS=$((WARNINGS + HITS)) - fi - done - - echo "" >> $GITHUB_STEP_SUMMARY - if [ "$WARNINGS" -gt 0 ]; then - echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY - else - echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY - fi - fi - - - name: Namespace consistency check - run: | - echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY - ERRORS=0 - - # Find component/plugin manifests with tags - MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '/dev/null || true) - - if [ -z "$MANIFESTS" ]; then - echo "No manifests with \`\` found — skipping." >> $GITHUB_STEP_SUMMARY - else - for MANIFEST in $MANIFESTS; do - NS_PATH=$(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1) - [ -z "$NS_PATH" ] && continue - MANIFEST_DIR=$(dirname "$MANIFEST") - - echo "Manifest: \`${MANIFEST}\` → namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY - - # Check PHP files have matching namespace - while IFS= read -r -d '' PHP_FILE; do - FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1) - [ -z "$FILE_NS" ] && continue - - # Namespace should start with the manifest namespace path - if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then - echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - fi - done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null) - done - fi - - echo "" >> $GITHUB_STEP_SUMMARY - if [ "${ERRORS}" -gt 0 ]; then - echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY - exit 1 - else - echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY - fi - - - name: SPDX license header check - continue-on-error: true - run: | - echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY - MISSING=0 - - SRC_DIR="" - for DIR in source/ src/ htdocs/; do - [ -d "$DIR" ] && SRC_DIR="$DIR" && break - done - - if [ -z "$SRC_DIR" ]; then - echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY - else - TOTAL=0 - while IFS= read -r -d '' FILE; do - TOTAL=$((TOTAL + 1)) - if ! head -10 "$FILE" | grep -qi "SPDX"; then - echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY - MISSING=$((MISSING + 1)) - fi - done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0) - - echo "" >> $GITHUB_STEP_SUMMARY - if [ "$MISSING" -gt 0 ]; then - echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY - else - echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY - fi - fi - - - name: Service provider check - run: | - echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY - ERRORS=0 - - PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) - if [ -z "$PROVIDERS" ]; then - echo "No service providers found — skipping." >> $GITHUB_STEP_SUMMARY - else - for FILE in $PROVIDERS; do - # Must return a ServiceProviderInterface - if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then - echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY - fi - - # Must have return statement - if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then - echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - fi - done - fi - - echo "" >> $GITHUB_STEP_SUMMARY - if [ "${ERRORS}" -gt 0 ]; then - echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY - exit 1 - else - echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY - fi - - release-readiness: - name: Release Readiness Check - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' && github.base_ref == 'main' - continue-on-error: true - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Validate release readiness - run: | - echo "## Release Readiness" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - ERRORS=0 - - # Extract version from README.md - README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md | head -1) - if [ -z "$README_VERSION" ]; then - echo "No VERSION found in README.md FILE INFORMATION block." >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - echo "README version: \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY - fi - - # Find the extension manifest - MANIFEST="" - for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do - if grep -q "/dev/null; then - MANIFEST="$XML_FILE" - break - fi - done - - if [ -z "$MANIFEST" ]; then - echo "No Joomla extension manifest found." >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - echo "Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY - - # Check matches README VERSION - MANIFEST_VERSION=$(grep -oP '\K[^<]+' "$MANIFEST" | head -1) - if [ -z "$MANIFEST_VERSION" ]; then - echo "No \`\` tag in manifest." >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - elif [ -n "$README_VERSION" ] && [ "$MANIFEST_VERSION" != "$README_VERSION" ]; then - echo "Manifest version \`${MANIFEST_VERSION}\` does not match README \`${README_VERSION}\`." >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - echo "Manifest version: \`${MANIFEST_VERSION}\`" >> $GITHUB_STEP_SUMMARY - fi - - # Check extension type, element, client attributes - EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1) - if [ -z "$EXT_TYPE" ]; then - echo "Missing \`type\` attribute on \`\` tag." >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - else - echo "Extension type: \`${EXT_TYPE}\`" >> $GITHUB_STEP_SUMMARY - fi - - # Element check (component/module/plugin name) - HAS_ELEMENT=$(grep -cP '<(element|name)>' "$MANIFEST" 2>/dev/null || echo "0") - if [ "$HAS_ELEMENT" -eq 0 ]; then - echo "Missing \`\` or \`\` in manifest." >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - fi - - # Client attribute for site/admin modules and plugins - if echo "$EXT_TYPE" | grep -qP "^(module|plugin)$"; then - HAS_CLIENT=$(grep -cP ']*\bclient=' "$MANIFEST" 2>/dev/null || echo "0") - if [ "$HAS_CLIENT" -eq 0 ]; then - echo "Missing \`client\` attribute for ${EXT_TYPE} extension." >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - fi - fi - fi - - # Check updates.xml exists - if [ -f "updates.xml" ] || [ -f "updates.xml" ]; then - echo "Update XML present." >> $GITHUB_STEP_SUMMARY - else - echo "No updates.xml found." >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - fi - - # Check CHANGELOG.md exists - if [ -f "CHANGELOG.md" ]; then - echo "CHANGELOG.md present." >> $GITHUB_STEP_SUMMARY - else - echo "No CHANGELOG.md found." >> $GITHUB_STEP_SUMMARY - ERRORS=$((ERRORS + 1)) - fi - - echo "" >> $GITHUB_STEP_SUMMARY - if [ $ERRORS -gt 0 ]; then - echo "**${ERRORS} issue(s) must be resolved before release.**" >> $GITHUB_STEP_SUMMARY - exit 1 - else - echo "**Extension is ready for release.**" >> $GITHUB_STEP_SUMMARY - fi - - test: - name: Tests (PHP ${{ matrix.php }}) - runs-on: ubuntu-latest - needs: lint-and-validate - - strategy: - fail-fast: false - matrix: - php: ['8.2', '8.3'] - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup PHP ${{ matrix.php }} - run: | - if ! command -v php &> /dev/null; then - sudo apt-get update -qq - sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 - fi - php -v && composer --version - - - name: Install dependencies - env: - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}' - run: | - if [ -f "composer.json" ]; then - composer install \ - --no-interaction \ - --prefer-dist \ - --optimize-autoloader - else - echo "No composer.json found — skipping dependency install" - fi - - - name: Run tests - run: | - echo "### Test Results (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY - if [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then - vendor/bin/phpunit --testdox 2>&1 | tee /tmp/test-output.log - EXIT=${PIPESTATUS[0]} - if [ $EXIT -eq 0 ]; then - echo "All tests passed." >> $GITHUB_STEP_SUMMARY - else - echo "Test failures detected — see log." >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - cat /tmp/test-output.log >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - fi - exit $EXIT - else - echo "No phpunit.xml found — skipping tests." >> $GITHUB_STEP_SUMMARY - fi - - static-analysis: - name: PHPStan Analysis - runs-on: ubuntu-latest - needs: lint-and-validate - continue-on-error: true - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup PHP - run: | - if ! command -v php &> /dev/null; then - sudo apt-get update -qq - sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 - fi - php -v && composer --version - - - name: Install dependencies - env: - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}' - run: | - if [ -f "composer.json" ]; then - composer install --no-interaction --prefer-dist --optimize-autoloader - fi - - - name: Install PHPStan - run: | - if ! command -v vendor/bin/phpstan &> /dev/null; then - composer require --dev phpstan/phpstan --no-interaction 2>/dev/null || \ - composer global require phpstan/phpstan --no-interaction - fi - - - name: Run PHPStan - run: | - echo "### PHPStan Static Analysis" >> $GITHUB_STEP_SUMMARY - PHPSTAN="vendor/bin/phpstan" - if [ ! -f "$PHPSTAN" ]; then - PHPSTAN=$(composer global config bin-dir --absolute 2>/dev/null)/phpstan - fi - - # Determine source directory - SRC_DIR="" - for DIR in src/ htdocs/ lib/; do - if [ -d "$DIR" ]; then - SRC_DIR="$DIR" - break - fi - done - - if [ -z "$SRC_DIR" ]; then - echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY - exit 0 - fi - - # Use repo phpstan.neon if present, otherwise use baseline config - ARGS="analyse ${SRC_DIR} --memory-limit=512M --no-progress --error-format=table" - if [ -f "phpstan.neon" ] || [ -f "phpstan.neon.dist" ]; then - echo "Using project PHPStan config." >> $GITHUB_STEP_SUMMARY - else - ARGS="$ARGS --level=3" - echo "No phpstan.neon found — using level 3 (type inference)." >> $GITHUB_STEP_SUMMARY - fi - - $PHPSTAN $ARGS 2>&1 | tee /tmp/phpstan-output.txt - EXIT=${PIPESTATUS[0]} - - if [ $EXIT -eq 0 ]; then - echo "**No errors found.**" >> $GITHUB_STEP_SUMMARY - else - ERRORS=$(grep -c "ERROR" /tmp/phpstan-output.txt 2>/dev/null || echo "some") - echo "**${ERRORS} error(s) found.** Review output above." >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - tail -30 /tmp/phpstan-output.txt >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - fi - exit $EXIT - - pre-release: - name: Build RC Pre-Release - runs-on: ubuntu-latest - needs: [lint-and-validate, test] - if: github.event_name == 'pull_request' - - steps: - - name: Trigger pre-release build - env: - GA_TOKEN: ${{ secrets.GA_TOKEN }} - REPO: ${{ github.repository }} - BRANCH: ${{ github.head_ref }} - run: | - curl -s -X POST \ - "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" \ - -H "Authorization: token ${GA_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}" - echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY - echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 47d0050..eb67f8f 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -3,9 +3,9 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # FILE INFORMATION -# DEFGROUP: Gitea.Workflow +# DEFGROUP: MokoGitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 06.00.25 +# VERSION: 01.00.00 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" @@ -19,7 +19,7 @@ permissions: issues: write env: - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} jobs: create-branch: @@ -28,8 +28,8 @@ jobs: steps: - name: Create branch and comment run: | - TOKEN="${{ secrets.GA_TOKEN }}" - API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}" ISSUE_NUM="${{ github.event.issue.number }}" ISSUE_TITLE="${{ github.event.issue.title }}" @@ -58,7 +58,7 @@ jobs: echo "Created branch: ${BRANCH}" # Comment on issue with branch link - REPO_URL="${GITEA_URL}/${{ github.repository }}" + REPO_URL="${MOKOGITEA_URL}/${{ github.repository }}" BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`" curl -sf -X POST \ diff --git a/.mokogitea/workflows/pr-metadata-check.yml b/.mokogitea/workflows/pr-metadata-check.yml index 68b7589..b4c9cbd 100644 --- a/.mokogitea/workflows/pr-metadata-check.yml +++ b/.mokogitea/workflows/pr-metadata-check.yml @@ -20,7 +20,7 @@ permissions: contents: read env: - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} @@ -55,14 +55,14 @@ jobs: - name: Validate metadata against Joomla manifest env: - GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} run: | php ${MOKO_CLI}/joomla_metadata_validate.php \ --path . \ - --token "${GITEA_TOKEN}" \ + --token "${MOKOGITEA_TOKEN}" \ --org "${GITEA_ORG}" \ --repo "${GITEA_REPO}" \ - --api-base "${GITEA_URL}/api/v1" \ + --api-base "${MOKOGITEA_URL}/api/v1" \ --ci if [ $? -ne 0 ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index 7552818..d001f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,24 +1,12 @@ - - - # Changelog -## [Unreleased] - -### Fixed -- **Honest install success** — the package installer no longer shows the license / next-steps message when a bundled child extension failed to install. A fail-open manifest verifier confirms every child extension landed in `#__extensions` and reports any that are missing. - +## [01.01.00] - 2026-06-12 ### Added -- **Repository** -- initial repo creation with scaffolding -- **System Plugin** -- Extension class, service provider -- **SQL Schema** -- 10 tables: work_orders, equipment, equipment_history, technicians, parts, truck_inventory, work_order_parts, checklists, pm_agreements, dispatches -- **Admin Component** -- 9 views: dashboard, work orders, equipment, technicians, parts, truck inventory, checklists, PM agreements, dispatches -- **Webservices Plugin** -- API route stubs -- **Configuration** -- settings across basic, dispatch, billing, scheduling -- **Access Control** -- permissions for granular role-based access +- Initial scaffold: field service management for MokoSuite +- 12 database tables for technicians, work orders, service agreements, equipment, vehicles, estimates +- 7 helpers: Dispatch, WorkOrder, ServiceAgreement, Equipment, Estimate, TruckStock, Vehicle +- Admin views: Dashboard, Work Orders, Technicians, Service Agreements, Equipment, Dispatch, Vehicles +- Site views: Tech Mobile (tablet), Book Service (public form) +- API controller with 6 endpoints +- Task scheduler: service reminders, agreement renewals, equipment warranty, truck stock reorder +- Joomla 6 architecture (PHP 8.3+) diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index b1609ce..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,31 +0,0 @@ -# MokoSuiteField - -Work orders, technician dispatch, equipment registry, parts inventory, and PM agreements for Joomla 6. - -## Quick Reference - -| Field | Value | -|---|---| -| **Package** | `pkg_mokosuitefield` | -| **Layer** | 2 (requires: Client, CRM) | -| **Language** | PHP 8.3+ | -| **Branch** | develop on `dev`, merge to `main` (protected) | -| **Wiki** | [MokoSuiteField Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteField/wiki) | - -## Architecture - -Joomla **package** -- Layer 2 add-on. CRM contacts as customers/technicians, work order management with dispatch, equipment tracking, parts inventory with truck stock, preventive maintenance agreements. - -## Rules - -- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js` -- **Attribution**: `Authored-by: Moko Consulting` -- **Workflow directory**: `.mokogitea/` -- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoCLI/wiki) -- **Changelog**: `[Unreleased]` only -- release system assigns versions - -## Coding Standards - -- PHP 8.3+ / Joomla 6 patterns -- `$this->getDatabase()` in models, `Factory::getContainer()->get(DatabaseInterface::class)` in helpers -- `Factory::getApplication()->getIdentity()` for user diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 2ecc653..72ce0eb 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,35 +1,46 @@ - # Contributor Covenant Code of Conduct ## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, colour, religion, or sexual -identity and orientation. +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. ## Our Standards +- Be empathetic and kind +- Be respectful of differing opinions +- Accept constructive feedback +- Own mistakes and learn from them -Examples of behaviour that contributes to a positive environment: - -- Using welcoming and inclusive language -- Being respectful of differing viewpoints and experiences -- Accepting constructive criticism gracefully -- Focusing on what is best for the community +Unacceptable behavior includes sexualized language/imagery, trolling, harassment, doxing, and other inappropriate conduct. ## Enforcement +Report incidents to **hello@mokoconsulting.tech** or through GitHub Discussions if you prefer a community-visible approach. Private complaints will be reviewed promptly and fairly. -Instances of abusive, harassing, or otherwise unacceptable behaviour may be -reported by contacting the project team at -[info@mokoconsulting.tech](mailto:info@mokoconsulting.tech). +## Enforcement Guidelines +1. **Correction** — Private warning +2. **Warning** — Formal warning and limited interaction +3. **Temporary Ban** — Time-boxed exclusion +4. **Permanent Ban** — Removal from the community ## Attribution - -This Code of Conduct is adapted from the -[Contributor Covenant](https://www.contributor-covenant.org), version 2.1. +Adapted from the Contributor Covenant v2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a91fea..bf60cc5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,88 +1,161 @@ - - # Contributing to Moko Consulting Projects -Thank you for your interest in contributing! This guide explains our workflow, -conventions, and how to get your changes merged. +Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy. ## Branching Workflow -We use a **stability-gated** branching model: - ``` -feature/* ──── PR ───→ dev - │ RC cut → rc → main -fix/* ───────── PR ────────────┘ +feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main ``` -1. **Create a branch** from `dev`: - - `feature/` for new functionality - - `fix/` for bug fixes - - `chore/` for maintenance -2. **Open a PR** into `dev`. -3. **CI must pass** before merge. -4. **Release cuts**: `dev → rc → main` are handled by maintainers. +### Step by step -> **Never commit directly to `main` or `dev`.** +1. **Create a feature branch** from `dev`: + ```bash + git checkout dev && git pull + git checkout -b feature/my-change + ``` + +2. **Work and commit** on your feature branch. Push to origin. + +3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it. + +4. **When ready for release**, open a **draft PR**: `dev` → `main`. + - This automatically renames the source branch to `rc` (release candidate) + - An RC pre-release is built and uploaded + +5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage: + - Rename `dev` to `alpha` for early testing → alpha pre-release is built + - Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built + - When the draft PR is created, the branch is renamed to `rc` + +6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`. + +7. **Merging to main** triggers the stable release pipeline: + - Minor version bump (e.g., `02.09.xx` → `02.10.00`) + - Stability suffix stripped (clean version) + - Gitea release created with ZIP/tar.gz packages + - `updates.xml` updated (Joomla extensions) + - `dev` branch recreated from `main` + +### Branch summary + +| Branch | Purpose | Created by | +|--------|---------|-----------| +| `feature/*` | New features and fixes | Developer | +| `dev` | Integration branch | Auto-recreated after release | +| `alpha` | Alpha pre-release testing | Manual rename from `dev` | +| `beta` | Beta pre-release testing | Manual rename from `alpha` | +| `rc` | Release candidate | Auto-renamed on draft PR to main | +| `main` | Stable releases | Protected, merge only | +| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI | + +### Protected branches + +| Branch | Direct push | Merge via | +|--------|------------|-----------| +| `main` | Blocked (CI bot whitelisted) | PR merge only | +| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* | +| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR | +| `alpha` | Blocked (CI bot whitelisted) | Manual rename | +| `beta` | Blocked (CI bot whitelisted) | Manual rename | +| `feature/*` | Open | N/A (source branch) | ## Version Policy -All repositories use the **XX.YY.ZZ** versioning scheme (two-digit segments): +### Format -- `XX` -- major (breaking changes) -- `YY` -- minor (new features, backward-compatible) -- `ZZ` -- patch (bug fixes, security patches) +All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded: -**Stability suffixes** may be appended during pre-release: +- **XX** — Major version (breaking changes) +- **YY** — Minor version (new features, bumped on release to main) +- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches) -| Suffix | Meaning | Example | -|---|---|---| -| `-alpha.N` | Early testing | `06.01.00-alpha.1` | -| `-beta.N` | Feature complete | `06.01.00-beta.2` | -| `-rc.N` | Release candidate | `06.01.00-rc.1` | -| *(none)* | Stable release | `06.01.00` | +Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major. -## Auto-Bump +### Stability suffixes -Version bumps are **automated** via the `auto-bump` workflow: +Each branch appends a suffix to indicate stability: -- Merges into `dev` trigger a minor/patch bump. -- The workflow updates all version references (manifests, changelog, etc.). -- **Do not manually edit version numbers** -- let the workflow handle it. +| Branch | Suffix | Example | +|--------|--------|---------| +| `main` | (none) | `02.09.00` | +| `dev` | `-dev` | `02.09.01-dev` | +| `feature/*` | `-dev` | `02.09.01-dev` | +| `alpha` | `-alpha` | `02.09.01-alpha` | +| `beta` | `-beta` | `02.09.01-beta` | +| `rc` | `-rc` | `02.09.01-rc` | + +### Auto version bump + +On every push to `dev`, `feature/*`, or `patch/*`: + +1. Patch version incremented +2. Stability suffix `-dev` applied +3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.) +4. Commit created with `[skip ci]` to avoid loops + +### Release version flow + +Version bumps happen at specific release events: + +| Event | Bump | Example | +|-------|------|---------| +| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` | +| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` | +| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) | +| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` | + +### Release stream copies + +When a higher-stability release is published, copies are created for all lesser streams with the same base version: + +- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta` +- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc` + +This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed). + +### Version files + +The version tools update all files containing version stamps: + +- `.mokogitea/manifest.xml` (canonical source) +- Joomla XML manifests (`` tag) +- `README.md`, `CHANGELOG.md` (`VERSION:` pattern) +- `package.json`, `pyproject.toml` +- Any text file with a `VERSION: XX.YY.ZZ` label + +Files synced from other repos (with a `# REPO:` header) are not touched. + +## Code Standards + +- **PHP**: PSR-12, tabs for indentation +- **Copyright**: all files must include the Moko Consulting copyright header +- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo) +- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names ## Commit Messages -We follow [Conventional Commits](https://www.conventionalcommits.org/): +Use conventional commit format: ``` -(): +type(scope): short description - +Optional body with context. - +Authored-by: Moko Consulting ``` -**Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert` +Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci` -## Pull Request Checklist +Special flags in commit messages: +- `[skip ci]` — skip all CI workflows +- `[skip bump]` — skip auto version bump only -Before submitting a PR, ensure: +## Reporting Issues -- [ ] Branch is based on latest `dev` -- [ ] Commit messages follow conventional commits -- [ ] CHANGELOG.md updated under `[Unreleased]` -- [ ] No `TODO.md`, `.claude/`, `.mcp.json`, or minified files included -- [ ] Code follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards) -- [ ] All CI checks pass +Use the repository's issue tracker with the appropriate template. -## Code of Conduct +--- -All contributors are expected to follow our [Code of Conduct](CODE_OF_CONDUCT.md). - -## Questions? - -Open a [Question issue](../../issues/new?template=question.md) or contact -us at [hello@mokoconsulting.tech](mailto:hello@mokoconsulting.tech). +*Moko Consulting * diff --git a/GOVERNANCE.md b/GOVERNANCE.md index aeb3e4b..85c1a61 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -1,49 +1,119 @@ -# Governance +[![MokoStandards](https://img.shields.io/badge/MokoStandards-04.00.04-blue)](https://github.com/mokoconsulting-tech/MokoStandards) -## Project Leadership +# Project Governance -This repository is maintained by **Moko Consulting** under a **sole operator** model. +## Overview -- **Lead Maintainer**: Jonathan Miller (@jmiller) -- **Organisation**: [Moko Consulting](https://mokoconsulting.tech) +This document defines the governance model for the `Template-Joomla` repository within the +`mokoconsulting-tech` organization. It is automatically maintained by +[MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards) v04.00.04. -## Decision Making +Full governance policy is defined in the MokoStandards source repository: +[docs/policy/GOVERNANCE.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/GOVERNANCE.md) -- All architectural decisions are made by the lead maintainer. -- Community feedback is welcome via [RFC issues](../../issues/new?template=rfc.md). -- Breaking changes are documented via [ADR issues](../../issues/new?template=adr.md). +--- -## Contribution Policy +## Roles and Responsibilities -- **All changes** must go through a pull request (PR). -- **CI checks** are mandatory before merge. -- **Direct push** to `main` and `dev` is restricted to automated workflows. +### Maintainer -## Code of Conduct +**GitHub**: @mokoconsulting-tech -All participants must adhere to our [Code of Conduct](CODE_OF_CONDUCT.md). +**Authority**: Final decision-making authority on all matters for this repository. -## Licensing +**Responsibilities**: +- Review and merge pull requests +- Maintain code quality and standards compliance +- Manage releases and versioning +- Respond to issues and security reports -All contributions are licensed under the same license as the project -(GPL-3.0-or-later unless otherwise stated in the repository root). +### Contributors -## Security +**Authority**: Submit changes via pull requests. -Security vulnerabilities should be reported privately. -See [SECURITY.md](SECURITY.md) for details. +**Requirements**: +- Read and accept `CODE_OF_CONDUCT.md` +- Follow `CONTRIBUTING.md` guidelines -## Dispute Resolution +--- -Disputes are resolved by the lead maintainer. For escalation, -contact [info@mokoconsulting.tech](mailto:info@mokoconsulting.tech). +## Decision-Making -## Changes to Governance +All changes must be submitted as pull requests. The maintainer (@mokoconsulting-tech) +reviews and approves all changes before they are merged. -This document may be updated at any time by the lead maintainer. -Significant changes will be announced via an RFC issue. +### Sole Operator Policy + +This organization operates under a **sole operator** model. The maintainer (@mokoconsulting-tech) +is the sole employee and owner and may self-approve pull requests when no second reviewer is +available. The following requirements remain mandatory regardless: + +1. **Pull Requests Required** — all changes to protected branches go through a PR. +2. **Automated Checks** — all CI checks must pass before merging. +3. **Audit Trail** — issues, pull requests, and commit history are preserved. +4. **Documentation** — changes are documented in `CHANGELOG.md`. + +See the full policy: +[Sole Operator Policy](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/GOVERNANCE.md#sole-operator-policy) + +--- + +## Change Management + +| Change Type | Approval | Process | +|-------------|----------|---------| +| Routine (docs, bug fixes) | Maintainer | PR → CI pass → merge | +| Significant (new features) | Maintainer | PR with description → CI pass → merge | +| Major (breaking, architecture) | Maintainer | Issue discussion → PR → CI pass → merge | +| Emergency (security) | Maintainer | Labelled `EMERGENCY` → immediate merge → post-mortem | + +--- + +## Reporting Issues + +- **Bugs / Features**: Open a [GitHub Issue](https://github.com/mokoconsulting-tech/Template-Joomla/issues) +- **Security vulnerabilities**: See [SECURITY.md](./SECURITY.md) +- **Code of Conduct**: See [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) +- **Contact**: dev@mokoconsulting.tech + +--- + +## Metadata + +| Field | Value | +| ------------- | ----------------------------------------------- | +| Document Type | Policy | +| Domain | Governance | +| Applies To | mokoconsulting-tech/Template-Joomla | +| Jurisdiction | Tennessee, USA | +| Maintainer | @mokoconsulting-tech | +| Standards | MokoStandards v04.00.04 | +| Repo | https://github.com/mokoconsulting-tech/Template-Joomla | +| Path | /GOVERNANCE.md | +| Status | Active — auto-maintained by MokoStandards | diff --git a/README.md b/README.md index 4a0eaf8..74f5607 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,3 @@ - +# MokoSuiteField -# MokoSuite Field - -Work orders, technician dispatch, equipment registry, parts inventory, and PM agreements module for MokoSuite on Joomla 6. - -## Overview - -MokoSuiteField is a Layer 2 module in the MokoSuite platform, building on MokoSuiteClient (Layer 0) and MokoSuiteCRM (Layer 1) to provide complete field service management operations. - -## Features - -- **Work Orders** -- service requests with categories, priorities, scheduling, photos, signatures -- **Technician Management** -- profiles linked to CRM contacts, specialties, certifications, GPS tracking -- **Equipment Registry** -- customer equipment tracking with service history, warranty, condition ratings -- **Parts Inventory** -- warehouse stock with pricing, minimum levels, supplier tracking -- **Truck Inventory** -- per-technician mobile parts stock with restocking alerts -- **Checklists** -- configurable inspection and safety checklists by category -- **PM Agreements** -- preventive maintenance contracts with visit scheduling and auto-renewal -- **Dispatch** -- technician assignment with distance/ETA tracking and multi-attempt offers - -## Requirements - -- Joomla 6.x -- PHP 8.3+ -- MokoSuiteClient (Layer 0) -- MokoSuiteCRM (Layer 1) - -## Installation - -Install via Joomla Extension Manager using the package file `pkg_mokosuitefield.zip`. - -## License - -GNU General Public License v3.0 or later -- see [LICENSE](LICENSE). - -## Links - -- [Documentation](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteField/wiki) -- [Issues](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteField/issues) -- [MokoSuite Platform](https://mokoconsulting.tech) +MokoSuite Field Service - dispatch, work orders, scheduling, mobile tech, plumbing, electrical, HVAC, service agreements. Layer 2 add-on for MokoSuite (requires CRM). \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md index 7f29d29..9ca5ddc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,90 +1,241 @@ # Security Policy +## Purpose and Scope + +This document defines the security vulnerability reporting, response, and disclosure policy for this Joomla Plugin template repository. It establishes the authoritative process for responsible disclosure, assessment, remediation, and communication of security issues. + ## Supported Versions -| Version | Supported | -|---|---| -| Latest stable | ✅ Full support | -| Previous major | ⚠️ Critical fixes only | -| Older | ❌ No support | +Security updates are provided for the following versions: + +| Version | Supported | +| ------- | ------------------ | +| 01.x.x | :white_check_mark: | +| < 01.0 | :x: | + +Only the current major version receives security updates. Users should upgrade to the latest supported version to receive security patches. ## Reporting a Vulnerability -**Do not report security vulnerabilities via public issues.** +### Where to Report -Instead, please report them privately: +**DO NOT** create public GitHub issues for security vulnerabilities. -1. **Email**: [security@mokoconsulting.tech](mailto:security@mokoconsulting.tech) -2. **Subject**: `[SECURITY] - ` +Report security vulnerabilities privately to: + +**Email**: `security@mokoconsulting.tech` + +**Subject Line**: `[SECURITY] Template-Joomla - Brief Description` ### What to Include -- Description of the vulnerability -- Steps to reproduce -- Affected versions -- Potential impact -- Suggested fix (if any) +A complete vulnerability report should include: + +1. **Description**: Clear explanation of the vulnerability +2. **Impact**: Potential security impact and severity assessment +3. **Affected Versions**: Which versions are vulnerable +4. **Reproduction Steps**: Detailed steps to reproduce the issue +5. **Proof of Concept**: Code, configuration, or demonstration (if applicable) +6. **Suggested Fix**: Proposed remediation (if known) +7. **Disclosure Timeline**: Your expectations for public disclosure + +### Response Timeline + +* **Initial Response**: Within 3 business days +* **Assessment Complete**: Within 7 business days +* **Fix Timeline**: Depends on severity (see below) +* **Disclosure**: Coordinated with reporter ## Severity Classification -| Severity | Description | Response Time | -|---|---|---| -| **Critical** | Remote code execution, SQL injection, auth bypass | 24 hours | -| **High** | XSS, CSRF, privilege escalation | 48 hours | -| **Medium** | Information disclosure, path traversal | 72 hours | -| **Low** | Best practice violation, hardening suggestion | Next release | +Vulnerabilities are classified using the following severity levels: -## Remediation Timeline +### Critical +* Remote code execution +* Authentication bypass +* Data breach or exposure of sensitive information +* **Fix Timeline**: 7 days -1. **Acknowledgement**: Within 24 hours of report -2. **Assessment**: Within 72 hours -3. **Fix development**: Based on severity -4. **Release**: Patch release with security advisory -5. **Disclosure**: Coordinated disclosure after fix is available +### High +* Privilege escalation +* SQL injection or command injection +* Cross-site scripting (XSS) with significant impact +* **Fix Timeline**: 14 days + +### Medium +* Information disclosure (limited scope) +* Denial of service +* Security misconfigurations with moderate impact +* **Fix Timeline**: 30 days + +### Low +* Security best practice violations +* Minor information leaks +* Issues requiring user interaction or complex preconditions +* **Fix Timeline**: 60 days or next release + +## Remediation Process + +1. **Acknowledgment**: Security team confirms receipt and begins investigation +2. **Assessment**: Vulnerability is validated, severity assigned, and impact analyzed +3. **Development**: Security patch is developed and tested +4. **Review**: Patch undergoes security review and validation +5. **Release**: Fixed version is released with security advisory +6. **Disclosure**: Public disclosure follows coordinated timeline + +## Security Advisories + +Security advisories are published via: + +* GitHub Security Advisories +* Release notes and CHANGELOG.md +* Email notification to project users (if mailing list is established) + +Advisories include: + +* CVE identifier (if applicable) +* Severity rating +* Affected versions +* Fixed versions +* Mitigation steps +* Attribution (with reporter consent) ## Security Best Practices -### For Contributors +For projects using this template: -- Never commit secrets, credentials, or API keys -- Use parameterised queries (no raw SQL concatenation) -- Validate and sanitise all user input -- Follow Joomla API for access control checks -- Use Joomla's `HTMLHelper` for output escaping -- Include SPDX license headers in all source files +### Required Controls -### For Users +* Enable GitHub security features (Dependabot, code scanning) +* Implement branch protection on `main` +* Require code review for all changes +* Enforce signed commits (recommended) +* Use secrets management (never commit credentials) +* Maintain security documentation +* Follow secure coding standards defined in MokoStandards -- Keep Joomla and all extensions updated -- Use strong, unique passwords -- Enable two-factor authentication -- Review file permissions regularly -- Monitor Joomla error logs +### Joomla Plugin Security -## Security Updates +* Follow Joomla security best practices +* Validate and sanitize all user input +* Use Joomla's database API to prevent SQL injection +* Properly escape output to prevent XSS +* Implement proper access control checks +* Use Joomla's session and authentication APIs +* Keep Joomla and dependencies up to date -Security patches are delivered through the standard update channel. -Critical fixes may receive an emergency out-of-band release. +### CI/CD Security -## Responsible Disclosure +* Validate all inputs +* Sanitize outputs +* Use least privilege access +* Pin dependencies with hash verification +* Scan for vulnerabilities in dependencies +* Audit third-party actions and tools -We follow coordinated disclosure practices: +#### Automated Security Scanning -- We will work with reporters to understand and reproduce the issue -- We will develop and test a fix -- We will credit reporters (with permission) in security advisories -- We ask that reporters allow reasonable time for a fix before public disclosure +All repositories SHOULD implement: -## Contact +**CodeQL Analysis**: +* Enabled for PHP and other supported languages +* Runs on: push to main, pull requests, weekly schedule +* Query sets: `security-extended` and `security-and-quality` +* Configuration: `.github/workflows/codeql-analysis.yml` -- **Security team**: [security@mokoconsulting.tech](mailto:security@mokoconsulting.tech) -- **General**: [hello@mokoconsulting.tech](mailto:hello@mokoconsulting.tech) +**Dependabot Security Updates**: +* Weekly scans for vulnerable dependencies +* Automated pull requests for security patches +* Configuration: `.github/dependabot.yml` + +**Secret Scanning**: +* Enabled by default with push protection +* Prevents accidental credential commits + +### Dependency Management + +* Keep dependencies up to date +* Monitor security advisories for dependencies +* Remove unused dependencies +* Audit new dependencies before adoption +* Document security-critical dependencies + +## Compliance and Governance + +This security policy is aligned with MokoStandards. Deviations require documented justification. + +Security policies are reviewed and updated at least annually or following significant security incidents. + +## Attribution and Recognition + +We acknowledge and appreciate responsible disclosure. With your permission, we will: + +* Credit you in security advisories +* List you in CHANGELOG.md for the fix release +* Recognize your contribution publicly (if desired) + +## Contact and Escalation + +* **Security Team**: security@mokoconsulting.tech +* **Primary Contact**: hello@mokoconsulting.tech +* **Escalation**: For urgent matters requiring immediate attention, contact the maintainer directly via GitHub + +## Out of Scope + +The following are explicitly out of scope: + +* Issues in third-party dependencies (report directly to maintainers) +* Social engineering attacks +* Physical security issues +* Denial of service via resource exhaustion without amplification +* Issues requiring physical access to systems +* Theoretical vulnerabilities without proof of exploitability --- -Thank you for helping keep Moko Consulting projects secure. +## Metadata + +| Field | Value | +| ------------ | ------------------------------------------------------------------------------------------------------------ | +| Document | Security Policy | +| Path | /SECURITY.md | +| Repository | [https://github.com/mokoconsulting-tech/Template-Joomla](https://github.com/mokoconsulting-tech/Template-Joomla) | +| Owner | Moko Consulting | +| Scope | Security vulnerability handling | +| Status | Active | +| Effective | 2026-01-16 | + +## Revision History + +| Date | Change Description | Author | +| ---------- | ------------------------------------------------- | --------------- | +| 2026-01-16 | Initial creation for template repository | Moko Consulting | diff --git a/composer.json b/composer.json index d938818..10afe3a 100644 --- a/composer.json +++ b/composer.json @@ -1,26 +1,27 @@ { - "name": "mokoconsulting/mokojoomgallery", - "description": "Joomla extension by Moko Consulting", - "type": "joomla-package", - "license": "GPL-3.0-or-later", - "homepage": "https://mokoconsulting.tech", - "authors": [ - { - "name": "Moko Consulting", - "email": "hello@mokoconsulting.tech" + "name": "mokoconsulting/mokojoomgallery", + "description": "Photo gallery management for Joomla — galleries, images, thumbnails, lightbox, and frontend display", + "type": "joomla-package", + "version": "01.00.00", + "license": "GPL-3.0-or-later", + "authors": [ + { + "name": "Moko Consulting", + "email": "hello@mokoconsulting.tech", + "homepage": "https://mokoconsulting.tech" + } + ], + "require": { + "php": ">=8.1" + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.7", + "phpstan/phpstan": "^1.10", + "joomla/coding-standards": "3.0.x-dev" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true } - ], - "require": { - "php": ">=8.1" - }, - "require-dev": { - "squizlabs/php_codesniffer": "^3.0", - "phpstan/phpstan": "^2.0", - "joomla/coding-standards": "dev-main" - }, - "config": { - "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": true - } - } } diff --git a/packages/MokoSuiteERP b/packages/MokoSuiteERP deleted file mode 160000 index 8c76969..0000000 --- a/packages/MokoSuiteERP +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8c76969ee850c86dd9d3ccb69e86ebd108a1c919 diff --git a/phpstan.neon b/phpstan.neon index d6e669c..3d4adca 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,20 +1,32 @@ # Copyright (C) 2026 Moko Consulting # SPDX-License-Identifier: GPL-3.0-or-later -# FILE INFORMATION -# DEFGROUP: Config.StaticAnalysis -# INGROUP: Development -# BRIEF: PHPStan configuration for Joomla extension static analysis +# +# PHPStan configuration for Joomla extension repositories. +# Extends the base MokoStandards config and adds Joomla framework class stubs +# so PHPStan can resolve Factory, CMSApplication, User, Table, etc. +# without requiring a full Joomla installation. parameters: - level: 5 - paths: - - src - scanDirectories: - # Joomla framework stubs (if available) - - vendor/joomla - ignoreErrors: - # Joomla service-container architecture: Factory/Container returns mixed - - '#Call to an undefined method Joomla\\CMS\\Application\\.*::get#i' - - '#Call to method .* on an unknown class Joomla\\Cms\\Extension\\.*#' - # Joomla MVC pattern: Table::getInstance returns Table|bool - - '#Method Joomla\\CMS\\Table\\Table::getInstance#' + level: 5 + + paths: + - src + + excludePaths: + - vendor + - node_modules + + # Joomla framework stubs — resolved via the enterprise package from vendor/ + stubFiles: + - vendor/mokoconsulting-tech/enterprise/templates/stubs/joomla.php + + # Suppress errors that are structural in Joomla's service-container architecture + ignoreErrors: + # Joomla's service-based dependency injection returns mixed from getApplication() + - '#Cannot call method .+ on Joomla\\CMS\\Application\\CMSApplication\|null#' + # Factory::getX() patterns are safe at runtime even when nullable in stubs + - '#Call to static method [a-zA-Z]+\(\) on an interface#' + + reportUnmatchedIgnoredErrors: false + checkMissingIterableValueType: false + checkGenericClassInNonGenericObjectType: false diff --git a/source/components/com_mokosuitefield/access.xml b/source/components/com_mokosuitefield/access.xml deleted file mode 100644 index 1a23e16..0000000 --- a/source/components/com_mokosuitefield/access.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - -
- - - - - - - -
-
diff --git a/source/components/com_mokosuitefield/com_mokosuitefield.xml b/source/components/com_mokosuitefield/com_mokosuitefield.xml deleted file mode 100644 index a3a73f0..0000000 --- a/source/components/com_mokosuitefield/com_mokosuitefield.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - com_mokosuitefield - 0.1.0 - 2026-06-27 - Moko Consulting - hello@mokoconsulting.tech - https://mokoconsulting.tech - Copyright (C) 2026 Moko Consulting - GPL-3.0-or-later - COM_MOKOSUITEFIELD_DESCRIPTION - - Moko\Component\MokoSuiteField - - - COM_MOKOSUITEFIELD - - COM_MOKOSUITEFIELD_MENU_DASHBOARD - COM_MOKOSUITEFIELD_MENU_WORKORDERS - COM_MOKOSUITEFIELD_MENU_TECHNICIANS - COM_MOKOSUITEFIELD_MENU_EQUIPMENT - COM_MOKOSUITEFIELD_MENU_PARTS - COM_MOKOSUITEFIELD_MENU_CHECKLISTS - COM_MOKOSUITEFIELD_MENU_AGREEMENTS - COM_MOKOSUITEFIELD_MENU_DISPATCHES - - - src - tmpl - services - language - access.xml - config.xml - - - - - en-GB/com_mokosuitefield.ini - en-GB/com_mokosuitefield.sys.ini - - diff --git a/source/components/com_mokosuitefield/config.xml b/source/components/com_mokosuitefield/config.xml deleted file mode 100644 index 9de19da..0000000 --- a/source/components/com_mokosuitefield/config.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - -
- -
-
diff --git a/source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.ini b/source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.ini deleted file mode 100644 index 3e6a97f..0000000 --- a/source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.ini +++ /dev/null @@ -1,14 +0,0 @@ -; Copyright (C) 2026 Moko Consulting -; SPDX-License-Identifier: GPL-3.0-or-later -; Authored-by: Moko Consulting - -COM_MOKOSUITEFIELD="MokoSuite Field" -COM_MOKOSUITEFIELD_DESCRIPTION="Field service management for contractors." -COM_MOKOSUITEFIELD_MENU_DASHBOARD="Dashboard" -COM_MOKOSUITEFIELD_MENU_WORKORDERS="Work Orders" -COM_MOKOSUITEFIELD_MENU_TECHNICIANS="Technicians" -COM_MOKOSUITEFIELD_MENU_EQUIPMENT="Equipment" -COM_MOKOSUITEFIELD_MENU_PARTS="Parts" -COM_MOKOSUITEFIELD_MENU_CHECKLISTS="Checklists" -COM_MOKOSUITEFIELD_MENU_AGREEMENTS="PM Agreements" -COM_MOKOSUITEFIELD_MENU_DISPATCHES="Dispatches" diff --git a/source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.sys.ini b/source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.sys.ini deleted file mode 100644 index 2891028..0000000 --- a/source/components/com_mokosuitefield/language/en-GB/com_mokosuitefield.sys.ini +++ /dev/null @@ -1,15 +0,0 @@ -; Copyright (C) 2026 Moko Consulting -; SPDX-License-Identifier: GPL-3.0-or-later -; Authored-by: Moko Consulting - -COM_MOKOSUITEFIELD="MokoSuite Field" -COM_MOKOSUITEFIELD_SHORT="Field" -COM_MOKOSUITEFIELD_DESCRIPTION="Field service management for contractors." -COM_MOKOSUITEFIELD_MENU_DASHBOARD="Dashboard" -COM_MOKOSUITEFIELD_MENU_WORKORDERS="Work Orders" -COM_MOKOSUITEFIELD_MENU_TECHNICIANS="Technicians" -COM_MOKOSUITEFIELD_MENU_EQUIPMENT="Equipment" -COM_MOKOSUITEFIELD_MENU_PARTS="Parts" -COM_MOKOSUITEFIELD_MENU_CHECKLISTS="Checklists" -COM_MOKOSUITEFIELD_MENU_AGREEMENTS="PM Agreements" -COM_MOKOSUITEFIELD_MENU_DISPATCHES="Dispatches" diff --git a/source/components/com_mokosuitefield/services/provider.php b/source/components/com_mokosuitefield/services/provider.php deleted file mode 100644 index b341658..0000000 --- a/source/components/com_mokosuitefield/services/provider.php +++ /dev/null @@ -1,41 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -\defined('_JEXEC') or die; - -use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface; -use Joomla\CMS\Extension\ComponentInterface; -use Joomla\CMS\Extension\MVCComponent; -use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory; -use Joomla\CMS\Extension\Service\Provider\MVCFactory; -use Joomla\CMS\Extension\Service\Provider\RouterFactory; -use Joomla\CMS\MVC\Factory\MVCFactoryInterface; -use Joomla\CMS\Component\Router\RouterFactoryInterface; -use Joomla\DI\Container; -use Joomla\DI\ServiceProviderInterface; - -return new class () implements ServiceProviderInterface { - public function register(Container $container): void - { - $container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoSuiteField')); - $container->registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoSuiteField')); - $container->registerServiceProvider(new RouterFactory('\\Moko\\Component\\MokoSuiteField')); - - $container->set( - ComponentInterface::class, - function (Container $container) { - $component = new MVCComponent(); - $component->setMVCFactory($container->get(MVCFactoryInterface::class)); - $component->setDispatcherFactory($container->get(ComponentDispatcherFactoryInterface::class)); - $component->setRouterFactory($container->get(RouterFactoryInterface::class)); - - return $component; - } - ); - } -}; diff --git a/source/components/com_mokosuitefield/src/Controller/DisplayController.php b/source/components/com_mokosuitefield/src/Controller/DisplayController.php deleted file mode 100644 index fe41440..0000000 --- a/source/components/com_mokosuitefield/src/Controller/DisplayController.php +++ /dev/null @@ -1,18 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\Controller; - -use Joomla\CMS\MVC\Controller\BaseController; - -\defined('_JEXEC') or die; - -class DisplayController extends BaseController -{ - protected $default_view = 'fielddashboard'; -} diff --git a/source/components/com_mokosuitefield/src/View/FieldAgreements/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldAgreements/HtmlView.php deleted file mode 100644 index 6450a43..0000000 --- a/source/components/com_mokosuitefield/src/View/FieldAgreements/HtmlView.php +++ /dev/null @@ -1,24 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\View\FieldAgreements; - -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -\defined('_JEXEC') or die; - -class HtmlView extends BaseHtmlView -{ - public function display($tpl = null): void - { - ToolbarHelper::title('MokoSuite Field — PM Agreements', 'contract'); - - parent::display($tpl); - } -} diff --git a/source/components/com_mokosuitefield/src/View/FieldChecklists/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldChecklists/HtmlView.php deleted file mode 100644 index 9205936..0000000 --- a/source/components/com_mokosuitefield/src/View/FieldChecklists/HtmlView.php +++ /dev/null @@ -1,24 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\View\FieldChecklists; - -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -\defined('_JEXEC') or die; - -class HtmlView extends BaseHtmlView -{ - public function display($tpl = null): void - { - ToolbarHelper::title('MokoSuite Field — Checklists', 'checklist'); - - parent::display($tpl); - } -} diff --git a/source/components/com_mokosuitefield/src/View/FieldDashboard/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldDashboard/HtmlView.php deleted file mode 100644 index 463d969..0000000 --- a/source/components/com_mokosuitefield/src/View/FieldDashboard/HtmlView.php +++ /dev/null @@ -1,24 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\View\FieldDashboard; - -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -\defined('_JEXEC') or die; - -class HtmlView extends BaseHtmlView -{ - public function display($tpl = null): void - { - ToolbarHelper::title('MokoSuite Field — Dashboard', 'home'); - - parent::display($tpl); - } -} diff --git a/source/components/com_mokosuitefield/src/View/FieldDispatches/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldDispatches/HtmlView.php deleted file mode 100644 index e53eca5..0000000 --- a/source/components/com_mokosuitefield/src/View/FieldDispatches/HtmlView.php +++ /dev/null @@ -1,24 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\View\FieldDispatches; - -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -\defined('_JEXEC') or die; - -class HtmlView extends BaseHtmlView -{ - public function display($tpl = null): void - { - ToolbarHelper::title('MokoSuite Field — Dispatches', 'location'); - - parent::display($tpl); - } -} diff --git a/source/components/com_mokosuitefield/src/View/FieldEquipment/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldEquipment/HtmlView.php deleted file mode 100644 index 5c87dac..0000000 --- a/source/components/com_mokosuitefield/src/View/FieldEquipment/HtmlView.php +++ /dev/null @@ -1,24 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\View\FieldEquipment; - -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -\defined('_JEXEC') or die; - -class HtmlView extends BaseHtmlView -{ - public function display($tpl = null): void - { - ToolbarHelper::title('MokoSuite Field — Equipment', 'cogs'); - - parent::display($tpl); - } -} diff --git a/source/components/com_mokosuitefield/src/View/FieldParts/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldParts/HtmlView.php deleted file mode 100644 index 3de237f..0000000 --- a/source/components/com_mokosuitefield/src/View/FieldParts/HtmlView.php +++ /dev/null @@ -1,24 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\View\FieldParts; - -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -\defined('_JEXEC') or die; - -class HtmlView extends BaseHtmlView -{ - public function display($tpl = null): void - { - ToolbarHelper::title('MokoSuite Field — Parts', 'cube'); - - parent::display($tpl); - } -} diff --git a/source/components/com_mokosuitefield/src/View/FieldTechnicians/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldTechnicians/HtmlView.php deleted file mode 100644 index 565314d..0000000 --- a/source/components/com_mokosuitefield/src/View/FieldTechnicians/HtmlView.php +++ /dev/null @@ -1,24 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\View\FieldTechnicians; - -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -\defined('_JEXEC') or die; - -class HtmlView extends BaseHtmlView -{ - public function display($tpl = null): void - { - ToolbarHelper::title('MokoSuite Field — Technicians', 'users'); - - parent::display($tpl); - } -} diff --git a/source/components/com_mokosuitefield/src/View/FieldWorkorders/HtmlView.php b/source/components/com_mokosuitefield/src/View/FieldWorkorders/HtmlView.php deleted file mode 100644 index 309eba0..0000000 --- a/source/components/com_mokosuitefield/src/View/FieldWorkorders/HtmlView.php +++ /dev/null @@ -1,24 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\View\FieldWorkorders; - -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -\defined('_JEXEC') or die; - -class HtmlView extends BaseHtmlView -{ - public function display($tpl = null): void - { - ToolbarHelper::title('MokoSuite Field — Work Orders', 'file-2'); - - parent::display($tpl); - } -} diff --git a/source/components/com_mokosuitefield/tmpl/fieldagreements/default.php b/source/components/com_mokosuitefield/tmpl/fieldagreements/default.php deleted file mode 100644 index 3b1fd45..0000000 --- a/source/components/com_mokosuitefield/tmpl/fieldagreements/default.php +++ /dev/null @@ -1,13 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -\defined('_JEXEC') or die; -?> -
-

MokoSuite Field — PM Agreements

-
diff --git a/source/components/com_mokosuitefield/tmpl/fieldchecklists/default.php b/source/components/com_mokosuitefield/tmpl/fieldchecklists/default.php deleted file mode 100644 index 539738a..0000000 --- a/source/components/com_mokosuitefield/tmpl/fieldchecklists/default.php +++ /dev/null @@ -1,13 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -\defined('_JEXEC') or die; -?> -
-

MokoSuite Field — Checklists

-
diff --git a/source/components/com_mokosuitefield/tmpl/fielddashboard/default.php b/source/components/com_mokosuitefield/tmpl/fielddashboard/default.php deleted file mode 100644 index 6edbc0a..0000000 --- a/source/components/com_mokosuitefield/tmpl/fielddashboard/default.php +++ /dev/null @@ -1,13 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -\defined('_JEXEC') or die; -?> -
-

MokoSuite Field — Dashboard

-
diff --git a/source/components/com_mokosuitefield/tmpl/fielddispatches/default.php b/source/components/com_mokosuitefield/tmpl/fielddispatches/default.php deleted file mode 100644 index a85727f..0000000 --- a/source/components/com_mokosuitefield/tmpl/fielddispatches/default.php +++ /dev/null @@ -1,13 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -\defined('_JEXEC') or die; -?> -
-

MokoSuite Field — Dispatches

-
diff --git a/source/components/com_mokosuitefield/tmpl/fieldequipment/default.php b/source/components/com_mokosuitefield/tmpl/fieldequipment/default.php deleted file mode 100644 index d603886..0000000 --- a/source/components/com_mokosuitefield/tmpl/fieldequipment/default.php +++ /dev/null @@ -1,13 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -\defined('_JEXEC') or die; -?> -
-

MokoSuite Field — Equipment

-
diff --git a/source/components/com_mokosuitefield/tmpl/fieldparts/default.php b/source/components/com_mokosuitefield/tmpl/fieldparts/default.php deleted file mode 100644 index 0442669..0000000 --- a/source/components/com_mokosuitefield/tmpl/fieldparts/default.php +++ /dev/null @@ -1,13 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -\defined('_JEXEC') or die; -?> -
-

MokoSuite Field — Parts

-
diff --git a/source/components/com_mokosuitefield/tmpl/fieldtechnicians/default.php b/source/components/com_mokosuitefield/tmpl/fieldtechnicians/default.php deleted file mode 100644 index 42d3720..0000000 --- a/source/components/com_mokosuitefield/tmpl/fieldtechnicians/default.php +++ /dev/null @@ -1,13 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -\defined('_JEXEC') or die; -?> -
-

MokoSuite Field — Technicians

-
diff --git a/source/components/com_mokosuitefield/tmpl/fieldworkorders/default.php b/source/components/com_mokosuitefield/tmpl/fieldworkorders/default.php deleted file mode 100644 index 27c139d..0000000 --- a/source/components/com_mokosuitefield/tmpl/fieldworkorders/default.php +++ /dev/null @@ -1,13 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -\defined('_JEXEC') or die; -?> -
-

MokoSuite Field — Work Orders

-
diff --git a/source/packages/com_mokosuitefield/admin/access.xml b/source/packages/com_mokosuitefield/admin/access.xml index 6df9f60..6f4f869 100644 --- a/source/packages/com_mokosuitefield/admin/access.xml +++ b/source/packages/com_mokosuitefield/admin/access.xml @@ -2,23 +2,10 @@
- - - - - - - - - - - - - - - + +
diff --git a/source/packages/com_mokosuitefield/admin/config.xml b/source/packages/com_mokosuitefield/admin/config.xml index a036fac..b17bb28 100644 --- a/source/packages/com_mokosuitefield/admin/config.xml +++ b/source/packages/com_mokosuitefield/admin/config.xml @@ -1,36 +1,25 @@ -
+
- - - - - + + + + + +
- - - - - - - - - + +
- - - + + +
-
- - - - - - +
+
diff --git a/source/packages/com_mokosuitefield/admin/services/provider.php b/source/packages/com_mokosuitefield/admin/services/provider.php index 6c33848..f056f8e 100644 --- a/source/packages/com_mokosuitefield/admin/services/provider.php +++ b/source/packages/com_mokosuitefield/admin/services/provider.php @@ -1,26 +1,18 @@ - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - defined('_JEXEC') or die; - +use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface; use Joomla\CMS\Extension\ComponentInterface; use Joomla\CMS\Extension\MVCComponent; -use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; use Joomla\DI\Container; use Joomla\DI\ServiceProviderInterface; - return new class implements ServiceProviderInterface { - public function register(Container $container): void { + public function register(Container $container): void + { $container->set(ComponentInterface::class, function (Container $container) { - $c = new MVCComponent($container->get(ComponentDispatcherFactoryInterface::class)); - $c->setMVCFactory($container->get(MVCFactoryInterface::class)); - return $c; + $component = new MVCComponent($container->get(ComponentDispatcherFactoryInterface::class)); + $component->setMVCFactory($container->get(MVCFactoryInterface::class)); + return $component; }); } }; diff --git a/source/packages/com_mokosuitefield/admin/src/Controller/DisplayController.php b/source/packages/com_mokosuitefield/admin/src/Controller/DisplayController.php index bf9436b..0129bef 100644 --- a/source/packages/com_mokosuitefield/admin/src/Controller/DisplayController.php +++ b/source/packages/com_mokosuitefield/admin/src/Controller/DisplayController.php @@ -1,11 +1,4 @@ - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - namespace Moko\Component\MokoSuiteField\Administrator\Controller; defined('_JEXEC') or die; @@ -14,5 +7,5 @@ use Joomla\CMS\MVC\Controller\BaseController; class DisplayController extends BaseController { - protected $default_view = 'fielddashboard'; + protected $default_view = 'dashboard'; } diff --git a/source/packages/com_mokosuitefield/admin/src/Model/DispatchModel.php b/source/packages/com_mokosuitefield/admin/src/Model/DispatchModel.php new file mode 100644 index 0000000..a6c00a1 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/Model/DispatchModel.php @@ -0,0 +1,37 @@ +getDatabase(); + $db->setQuery($db->getQuery(true) + ->select('t.id AS tech_id, cd.name AS tech_name, t.trade, t.status AS tech_status') + ->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.scheduled_date = CURDATE() AND wo.status NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ')) AS pending_jobs') + ->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.scheduled_date = CURDATE() AND wo.status = ' . $db->quote('completed') . ') AS completed_jobs') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->where($db->quoteName('t.status') . ' != ' . $db->quote('inactive')) + ->order('cd.name ASC')); + return $db->loadObjectList() ?: []; + } + + public function getGpsLog(int $techId, string $date = ''): array + { + $db = $this->getDatabase(); + $date = $date ?: date('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitefield_dispatch_log') + ->where('technician_id = ' . $techId) + ->where('DATE(recorded_at) = ' . $db->quote($date)) + ->order('recorded_at ASC')); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/Model/EquipmentModel.php b/source/packages/com_mokosuitefield/admin/src/Model/EquipmentModel.php new file mode 100644 index 0000000..2f244ad --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/Model/EquipmentModel.php @@ -0,0 +1,29 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('eq.*, loc.name AS location_name, loc.address, cd.name AS customer_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'eq')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = eq.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = eq.contact_id') + ->order('eq.name ASC'); + + if ($type) $query->where($db->quoteName('eq.type') . ' = ' . $db->quote($type)); + if ($status) $query->where($db->quoteName('eq.status') . ' = ' . $db->quote($status)); + if ($locationId) $query->where('eq.location_id = ' . $locationId); + if ($search) $query->where('(' . $db->quoteName('eq.name') . ' LIKE ' . $db->quote('%' . $search . '%') + . ' OR ' . $db->quoteName('eq.serial_number') . ' LIKE ' . $db->quote('%' . $search . '%') . ')'); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/Model/EstimatesModel.php b/source/packages/com_mokosuitefield/admin/src/Model/EstimatesModel.php new file mode 100644 index 0000000..7332a46 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/Model/EstimatesModel.php @@ -0,0 +1,28 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('e.*, cd.name AS customer_name, loc.address') + ->from($db->quoteName('#__mokosuitefield_estimates', 'e')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id') + ->order('e.created DESC'); + + if ($status) $query->where($db->quoteName('e.status') . ' = ' . $db->quote($status)); + if ($techId) $query->where('e.technician_id = ' . $techId); + if ($search) $query->where('(' . $db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%') + . ' OR ' . $db->quoteName('e.estimate_number') . ' LIKE ' . $db->quote('%' . $search . '%') . ')'); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/Model/FieldDashboardModel.php b/source/packages/com_mokosuitefield/admin/src/Model/FieldDashboardModel.php deleted file mode 100644 index 4e7135e..0000000 --- a/source/packages/com_mokosuitefield/admin/src/Model/FieldDashboardModel.php +++ /dev/null @@ -1,17 +0,0 @@ - - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\Model; - -defined('_JEXEC') or die; - -use Joomla\CMS\MVC\Model\BaseDatabaseModel; - -class FieldDashboardModel extends BaseDatabaseModel -{ -} diff --git a/source/packages/com_mokosuitefield/admin/src/Model/ServiceAgreementsModel.php b/source/packages/com_mokosuitefield/admin/src/Model/ServiceAgreementsModel.php new file mode 100644 index 0000000..c01a746 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/Model/ServiceAgreementsModel.php @@ -0,0 +1,39 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('sa.*, cd.name AS customer_name, loc.address') + ->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = sa.location_id') + ->order('sa.end_date ASC'); + + if ($status) $query->where($db->quoteName('sa.status') . ' = ' . $db->quote($status)); + if ($search) $query->where($db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%')); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } + + public function getExpiringSoon(int $days = 30): array + { + $db = $this->getDatabase(); + $db->setQuery($db->getQuery(true) + ->select('sa.*, cd.name AS customer_name') + ->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id') + ->where($db->quoteName('sa.status') . ' = ' . $db->quote('active')) + ->where($db->quoteName('sa.end_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $days . ' DAY)') + ->order('sa.end_date ASC')); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/Model/TechniciansModel.php b/source/packages/com_mokosuitefield/admin/src/Model/TechniciansModel.php new file mode 100644 index 0000000..6a32578 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/Model/TechniciansModel.php @@ -0,0 +1,27 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('t.*, cd.name AS tech_name, cd.email_to, cd.telephone') + ->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.status IN (' . $db->quote('dispatched') . ',' . $db->quote('in_progress') . ')) AS active_jobs') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->order('cd.name ASC'); + + if ($status) $query->where($db->quoteName('t.status') . ' = ' . $db->quote($status)); + if ($trade) $query->where($db->quoteName('t.trade') . ' = ' . $db->quote($trade)); + if ($search) $query->where($db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%')); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/Model/VehiclesModel.php b/source/packages/com_mokosuitefield/admin/src/Model/VehiclesModel.php new file mode 100644 index 0000000..0429c3a --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/Model/VehiclesModel.php @@ -0,0 +1,26 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('v.*, cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_vehicles', 'v')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.technician_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->order('v.vehicle_name ASC'); + + if ($status) $query->where($db->quoteName('v.status') . ' = ' . $db->quote($status)); + if ($techId) $query->where('v.technician_id = ' . $techId); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/Model/WorkOrdersModel.php b/source/packages/com_mokosuitefield/admin/src/Model/WorkOrdersModel.php new file mode 100644 index 0000000..af03434 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/Model/WorkOrdersModel.php @@ -0,0 +1,62 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, loc.address, loc.city, loc.state, loc.zip') + ->select('t_cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id') + ->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id') + ->order('wo.scheduled_date DESC, wo.priority DESC'); + + if ($status) $query->where($db->quoteName('wo.status') . ' = ' . $db->quote($status)); + if ($trade) $query->where($db->quoteName('wo.trade') . ' = ' . $db->quote($trade)); + if ($techId) $query->where('wo.technician_id = ' . $techId); + if ($date) $query->where('wo.scheduled_date = ' . $db->quote($date)); + if ($search) { + $query->where('(' . $db->quoteName('wo.wo_number') . ' LIKE ' . $db->quote('%' . $search . '%') + . ' OR ' . $db->quoteName('wo.description') . ' LIKE ' . $db->quote('%' . $search . '%') + . ' OR ' . $db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%') . ')'); + } + + $db->setQuery($query, $offset, $limit); + return $db->loadObjectList() ?: []; + } + + public function getWorkOrder(int $id): ?object + { + $db = $this->getDatabase(); + $db->setQuery($db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, t_cd.name AS tech_name') + ->select('loc.address, loc.city, loc.state, loc.zip, loc.latitude, loc.longitude, loc.name AS location_name') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id') + ->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id') + ->where('wo.id = ' . (int) $id)); + return $db->loadObject(); + } + + public function getStatusCounts(): object + { + $db = $this->getDatabase(); + $db->setQuery($db->getQuery(true) + ->select('status, COUNT(*) AS cnt') + ->from('#__mokosuitefield_work_orders') + ->group('status')); + $rows = $db->loadObjectList('status') ?: []; + return (object) array_map(fn($r) => (int) $r->cnt, (array) $rows); + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/View/Checklists/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Checklists/HtmlView.php deleted file mode 100644 index 2a11464..0000000 --- a/source/packages/com_mokosuitefield/admin/src/View/Checklists/HtmlView.php +++ /dev/null @@ -1,23 +0,0 @@ - - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\View\Checklists; - -defined('_JEXEC') or die; - -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -class HtmlView extends BaseHtmlView -{ - public function display($tpl = null): void - { - ToolbarHelper::title('MokoSuite Field - Checklists'); - parent::display($tpl); - } -} diff --git a/source/packages/com_mokosuitefield/admin/src/View/Dashboard/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Dashboard/HtmlView.php new file mode 100644 index 0000000..c6b9eaf --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/View/Dashboard/HtmlView.php @@ -0,0 +1,40 @@ +stats = \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::getDashboardStats(); + $this->dispatchBoard = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getDispatchBoard(); + $this->unassigned = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getUnassigned(); + + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Urgent/emergency jobs + $db->setQuery($db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, loc.address') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->where($db->quoteName('wo.priority') . ' IN (' . $db->quote('emergency') . ',' . $db->quote('urgent') . ')') + ->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ',' . $db->quote('invoiced') . ')') + ->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ') ASC, wo.created ASC'), 0, 10); + $this->urgent = $db->loadObjectList() ?: []; + + ToolbarHelper::title('MokoSuite Field Service', 'icon-wrench'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/View/Dispatch/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Dispatch/HtmlView.php new file mode 100644 index 0000000..e59bd25 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/View/Dispatch/HtmlView.php @@ -0,0 +1,28 @@ +date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d')); + + $this->board = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getDispatchBoard($this->date); + $this->unassigned = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getUnassigned(); + $this->stats = \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::getDashboardStats(); + + ToolbarHelper::title('Field Service - Dispatch Board', 'icon-map'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/View/Dispatches/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Dispatches/HtmlView.php deleted file mode 100644 index 13798a5..0000000 --- a/source/packages/com_mokosuitefield/admin/src/View/Dispatches/HtmlView.php +++ /dev/null @@ -1,23 +0,0 @@ - - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\View\Dispatches; - -defined('_JEXEC') or die; - -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -class HtmlView extends BaseHtmlView -{ - public function display($tpl = null): void - { - ToolbarHelper::title('MokoSuite Field - Dispatches'); - parent::display($tpl); - } -} diff --git a/source/packages/com_mokosuitefield/admin/src/View/Equipment/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Equipment/HtmlView.php index e9c6e74..5188748 100644 --- a/source/packages/com_mokosuitefield/admin/src/View/Equipment/HtmlView.php +++ b/source/packages/com_mokosuitefield/admin/src/View/Equipment/HtmlView.php @@ -1,23 +1,34 @@ - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - namespace Moko\Component\MokoSuiteField\Administrator\View\Equipment; defined('_JEXEC') or die; +use Joomla\CMS\Factory; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; use Joomla\CMS\Toolbar\ToolbarHelper; +use Joomla\Database\DatabaseInterface; class HtmlView extends BaseHtmlView { + public array $equipment = []; + public array $serviceDue = []; + public function display($tpl = null): void { - ToolbarHelper::title('MokoSuite Field - Equipment'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('e.*, loc.address, loc.city, cd.name AS owner_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'e')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->order('e.equipment_type ASC, e.make ASC')); + $this->equipment = $db->loadObjectList() ?: []; + + $this->serviceDue = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getDueForService(30); + + ToolbarHelper::title('Field Service — Equipment', 'icon-cogs'); + ToolbarHelper::addNew('equipment.add'); parent::display($tpl); } } diff --git a/source/packages/com_mokosuitefield/admin/src/View/FieldDashboard/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/FieldDashboard/HtmlView.php deleted file mode 100644 index a1a7bd7..0000000 --- a/source/packages/com_mokosuitefield/admin/src/View/FieldDashboard/HtmlView.php +++ /dev/null @@ -1,23 +0,0 @@ - - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\View\FieldDashboard; - -defined('_JEXEC') or die; - -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -class HtmlView extends BaseHtmlView -{ - public function display($tpl = null): void - { - ToolbarHelper::title('MokoSuite Field - Dashboard'); - parent::display($tpl); - } -} diff --git a/source/packages/com_mokosuitefield/admin/src/View/Parts/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Parts/HtmlView.php deleted file mode 100644 index 5297f04..0000000 --- a/source/packages/com_mokosuitefield/admin/src/View/Parts/HtmlView.php +++ /dev/null @@ -1,23 +0,0 @@ - - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\View\Parts; - -defined('_JEXEC') or die; - -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -class HtmlView extends BaseHtmlView -{ - public function display($tpl = null): void - { - ToolbarHelper::title('MokoSuite Field - Parts'); - parent::display($tpl); - } -} diff --git a/source/packages/com_mokosuitefield/admin/src/View/PmAgreements/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/PmAgreements/HtmlView.php deleted file mode 100644 index 6f7ed2c..0000000 --- a/source/packages/com_mokosuitefield/admin/src/View/PmAgreements/HtmlView.php +++ /dev/null @@ -1,23 +0,0 @@ - - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\View\PmAgreements; - -defined('_JEXEC') or die; - -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -class HtmlView extends BaseHtmlView -{ - public function display($tpl = null): void - { - ToolbarHelper::title('MokoSuite Field - PM Agreements'); - parent::display($tpl); - } -} diff --git a/source/packages/com_mokosuitefield/admin/src/View/ServiceAgreements/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/ServiceAgreements/HtmlView.php new file mode 100644 index 0000000..ecec27f --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/View/ServiceAgreements/HtmlView.php @@ -0,0 +1,23 @@ +agreements = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getActiveAgreements(); + $this->revenue = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getRevenueSummary(); + + ToolbarHelper::title('Field Service — Service Agreements', 'icon-file-contract'); + ToolbarHelper::addNew('serviceagreements.add'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/View/Technicians/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Technicians/HtmlView.php index 3d73165..9f6f903 100644 --- a/source/packages/com_mokosuitefield/admin/src/View/Technicians/HtmlView.php +++ b/source/packages/com_mokosuitefield/admin/src/View/Technicians/HtmlView.php @@ -1,23 +1,32 @@ - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - namespace Moko\Component\MokoSuiteField\Administrator\View\Technicians; defined('_JEXEC') or die; +use Joomla\CMS\Factory; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; use Joomla\CMS\Toolbar\ToolbarHelper; +use Joomla\Database\DatabaseInterface; class HtmlView extends BaseHtmlView { + public array $technicians = []; + public function display($tpl = null): void { - ToolbarHelper::title('MokoSuite Field - Technicians'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('t.*, cd.name AS tech_name, cd.telephone, cd.email_to, v.vehicle_number') + ->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.status = ' . $db->quote('completed') . ' AND MONTH(wo.actual_departure) = MONTH(NOW())) AS jobs_this_month') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_vehicles', 'v') . ' ON v.id = t.vehicle_id') + ->order('cd.name ASC')); + $this->technicians = $db->loadObjectList() ?: []; + + ToolbarHelper::title('Field Service — Technicians', 'icon-users'); + ToolbarHelper::addNew('technicians.add'); parent::display($tpl); } } diff --git a/source/packages/com_mokosuitefield/admin/src/View/TruckInventory/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/TruckInventory/HtmlView.php deleted file mode 100644 index 6c21e29..0000000 --- a/source/packages/com_mokosuitefield/admin/src/View/TruckInventory/HtmlView.php +++ /dev/null @@ -1,23 +0,0 @@ - - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - -namespace Moko\Component\MokoSuiteField\Administrator\View\TruckInventory; - -defined('_JEXEC') or die; - -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -class HtmlView extends BaseHtmlView -{ - public function display($tpl = null): void - { - ToolbarHelper::title('MokoSuite Field - Truck Inventory'); - parent::display($tpl); - } -} diff --git a/source/packages/com_mokosuitefield/admin/src/View/Vehicles/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/Vehicles/HtmlView.php new file mode 100644 index 0000000..1ead5bc --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/src/View/Vehicles/HtmlView.php @@ -0,0 +1,23 @@ +vehicles = \Moko\Plugin\System\MokoSuiteField\Helper\VehicleHelper::getFleet(); + $this->inspectionsDue = \Moko\Plugin\System\MokoSuiteField\Helper\VehicleHelper::getInspectionsDue(30); + + ToolbarHelper::title('Field Service — Vehicles', 'icon-truck'); + ToolbarHelper::addNew('vehicles.add'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/admin/src/View/WorkOrders/HtmlView.php b/source/packages/com_mokosuitefield/admin/src/View/WorkOrders/HtmlView.php index 0cd88b0..06d57c4 100644 --- a/source/packages/com_mokosuitefield/admin/src/View/WorkOrders/HtmlView.php +++ b/source/packages/com_mokosuitefield/admin/src/View/WorkOrders/HtmlView.php @@ -1,23 +1,52 @@ - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - namespace Moko\Component\MokoSuiteField\Administrator\View\WorkOrders; defined('_JEXEC') or die; +use Joomla\CMS\Factory; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; use Joomla\CMS\Toolbar\ToolbarHelper; +use Joomla\Database\DatabaseInterface; class HtmlView extends BaseHtmlView { + public array $orders = []; + public array $filters = []; + public function display($tpl = null): void { - ToolbarHelper::title('MokoSuite Field - Work Orders'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + + $this->filters = [ + 'status' => $input->getString('filter_status', ''), + 'trade' => $input->getString('filter_trade', ''), + 'date' => $input->getString('filter_date', ''), + 'search' => $input->getString('filter_search', ''), + ]; + + $query = $db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, loc.address, loc.city, t_cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id') + ->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id') + ->order('wo.created DESC'); + + if ($this->filters['status']) $query->where($db->quoteName('wo.status') . ' = ' . $db->quote($this->filters['status'])); + if ($this->filters['trade']) $query->where($db->quoteName('wo.trade') . ' = ' . $db->quote($this->filters['trade'])); + if ($this->filters['date']) $query->where($db->quoteName('wo.scheduled_date') . ' = ' . $db->quote($this->filters['date'])); + if ($this->filters['search']) { + $like = $db->quote('%' . $db->escape($this->filters['search'], true) . '%'); + $query->where('(wo.wo_number LIKE ' . $like . ' OR cd.name LIKE ' . $like . ')'); + } + + $db->setQuery($query, 0, 100); + $this->orders = $db->loadObjectList() ?: []; + + ToolbarHelper::title('Field Service — Work Orders', 'icon-wrench'); + ToolbarHelper::addNew('workorders.add'); parent::display($tpl); } } diff --git a/source/packages/com_mokosuitefield/admin/tmpl/checklists/default.php b/source/packages/com_mokosuitefield/admin/tmpl/checklists/default.php deleted file mode 100644 index 6ea858b..0000000 --- a/source/packages/com_mokosuitefield/admin/tmpl/checklists/default.php +++ /dev/null @@ -1 +0,0 @@ -

Checklists

Coming soon.

diff --git a/source/packages/com_mokosuitefield/admin/tmpl/dashboard/default.php b/source/packages/com_mokosuitefield/admin/tmpl/dashboard/default.php new file mode 100644 index 0000000..ecd06c8 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/tmpl/dashboard/default.php @@ -0,0 +1,33 @@ +stats; +$board = $this->dispatchBoard; +$unassigned = $this->unassigned; +$urgent = $this->urgent; +?> +
+
total_today; ?>
Today
+
urgent; ?>
Urgent
+
unassigned; ?>
Unassigned
+
en_route; ?>
En Route
+
on_site; ?>
On Site
+
completed; ?>
Done
+
+
+
Dispatch Board
+ +
+
escape($tech->tech_name); ?>trade); ?>
+jobs)) : foreach ($tech->jobs as $job) : ?> +
escape($job->wo_number); ?> escape($job->customer_name ?? ''); ?> escape($job->city ?? ''); ?>
+
No jobs
+
+ +
+
Unassigned ()
+ +
escape($u->customer_name ?? ''); ?>
escape($u->category ?? $u->trade); ?>
+ +
All assigned
+
+
diff --git a/source/packages/com_mokosuitefield/admin/tmpl/dispatch/default.php b/source/packages/com_mokosuitefield/admin/tmpl/dispatch/default.php new file mode 100644 index 0000000..4cf6a08 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/tmpl/dispatch/default.php @@ -0,0 +1,10 @@ +board;$s=$this->stats; +?> +
total_today; ?>
Today
urgent; ?>
Urgent
en_route; ?>
En Route
completed; ?>
Done
+ +
escape($tech->tech_name); ?> +jobs as $job): ?>
escape($job->wo_number); ?> escape($job->customer_name); ?>
+
+ diff --git a/source/packages/com_mokosuitefield/admin/tmpl/dispatches/default.php b/source/packages/com_mokosuitefield/admin/tmpl/dispatches/default.php deleted file mode 100644 index 491c1d4..0000000 --- a/source/packages/com_mokosuitefield/admin/tmpl/dispatches/default.php +++ /dev/null @@ -1 +0,0 @@ -

Dispatches

Coming soon.

diff --git a/source/packages/com_mokosuitefield/admin/tmpl/equipment/default.php b/source/packages/com_mokosuitefield/admin/tmpl/equipment/default.php index 289ad6c..12ec110 100644 --- a/source/packages/com_mokosuitefield/admin/tmpl/equipment/default.php +++ b/source/packages/com_mokosuitefield/admin/tmpl/equipment/default.php @@ -1 +1,10 @@ -

Equipment

Coming soon.

+equipment;$due=$this->serviceDue; +?> +
equipment due
+ + + + +
TypeMake/ModelSerialOwnerLast Service
equipment_type)); ?>escape($e->make." ".$e->model); ?>escape($e->serial_number); ?>escape($e->owner_name); ?>last_service_date?date("M j",strtotime($e->last_service_date)):"Never"; ?>
diff --git a/source/packages/com_mokosuitefield/admin/tmpl/fielddashboard/default.php b/source/packages/com_mokosuitefield/admin/tmpl/fielddashboard/default.php deleted file mode 100644 index e80e602..0000000 --- a/source/packages/com_mokosuitefield/admin/tmpl/fielddashboard/default.php +++ /dev/null @@ -1 +0,0 @@ -

Dashboard

Coming soon.

diff --git a/source/packages/com_mokosuitefield/admin/tmpl/parts/default.php b/source/packages/com_mokosuitefield/admin/tmpl/parts/default.php deleted file mode 100644 index 33bddb6..0000000 --- a/source/packages/com_mokosuitefield/admin/tmpl/parts/default.php +++ /dev/null @@ -1 +0,0 @@ -

Parts

Coming soon.

diff --git a/source/packages/com_mokosuitefield/admin/tmpl/pmagreements/default.php b/source/packages/com_mokosuitefield/admin/tmpl/pmagreements/default.php deleted file mode 100644 index 823c4ef..0000000 --- a/source/packages/com_mokosuitefield/admin/tmpl/pmagreements/default.php +++ /dev/null @@ -1 +0,0 @@ -

PM Agreements

Coming soon.

diff --git a/source/packages/com_mokosuitefield/admin/tmpl/serviceagreements/default.php b/source/packages/com_mokosuitefield/admin/tmpl/serviceagreements/default.php new file mode 100644 index 0000000..13e6bb3 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/tmpl/serviceagreements/default.php @@ -0,0 +1,8 @@ +agreements; $rev=$this->revenue; ?> +
active_agreements; ?>
Active Agreements
$annual_recurring,0); ?>
Annual Recurring
$monthly_recurring,0); ?>
Monthly
+ + + + + +
AgreementCustomerTradeVisitsAnnualStatusExpires
title); ?>customer_name??""); ?>trade); ?>visits_remaining; ?> of visits_per_year; ?> left$annual_amount,0); ?>">status); ?>end_date?date("M j, Y",strtotime($a->end_date)):"—"; ?>
No agreements
diff --git a/source/packages/com_mokosuitefield/admin/tmpl/technicians/default.php b/source/packages/com_mokosuitefield/admin/tmpl/technicians/default.php index 7812c7d..8a25af9 100644 --- a/source/packages/com_mokosuitefield/admin/tmpl/technicians/default.php +++ b/source/packages/com_mokosuitefield/admin/tmpl/technicians/default.php @@ -1 +1,7 @@ -

Technicians

Coming soon.

+technicians; $statusColors=["available"=>"success","dispatched"=>"info","en_route"=>"warning","on_site"=>"primary","off_duty"=>"secondary","on_leave"=>"dark"]; ?> + + + + + +
TechTradeStatusPhoneVehicleLicenseJobs/Month
tech_name??""); ?>trade); ?>">status)); ?>telephone??""); ?>vehicle_number??"—"); ?>license_number??"—"); ?>jobs_this_month??0); ?>
No technicians
diff --git a/source/packages/com_mokosuitefield/admin/tmpl/truckinventory/default.php b/source/packages/com_mokosuitefield/admin/tmpl/truckinventory/default.php deleted file mode 100644 index 42b2624..0000000 --- a/source/packages/com_mokosuitefield/admin/tmpl/truckinventory/default.php +++ /dev/null @@ -1 +0,0 @@ -

Truck Inventory

Coming soon.

diff --git a/source/packages/com_mokosuitefield/admin/tmpl/vehicles/default.php b/source/packages/com_mokosuitefield/admin/tmpl/vehicles/default.php new file mode 100644 index 0000000..4ec6984 --- /dev/null +++ b/source/packages/com_mokosuitefield/admin/tmpl/vehicles/default.php @@ -0,0 +1,9 @@ +vehicles; +?> + + + + +
VehicleMake/ModelAssigned ToMileageStatus
escape($v->vehicle_number); ?>escape($v->make." ".$v->model); ?>escape($v->assigned_tech_name); ?>mileage?number_format((int)$v->mileage):"—"; ?>status); ?>
diff --git a/source/packages/com_mokosuitefield/admin/tmpl/workorders/default.php b/source/packages/com_mokosuitefield/admin/tmpl/workorders/default.php index f820055..00b7a70 100644 --- a/source/packages/com_mokosuitefield/admin/tmpl/workorders/default.php +++ b/source/packages/com_mokosuitefield/admin/tmpl/workorders/default.php @@ -1 +1,34 @@ -

Work Orders

Coming soon.

+orders; +$f = $this->filters; +$statusColors = ['new'=>'secondary','dispatched'=>'info','en_route'=>'warning','on_site'=>'primary','in_progress'=>'primary','parts_needed'=>'danger','completed'=>'success','invoiced'=>'dark','cancelled'=>'danger']; +$priorityColors = ['emergency'=>'danger','urgent'=>'warning','high'=>'info','normal'=>'primary','low'=>'secondary','scheduled'=>'dark']; +?> + +
+
+
+
+
+
+ + + + + + + + + + + + + + +
WO#CustomerTradePriorityStatusTechnicianScheduledTotal
escape($wo->wo_number); ?>escape($wo->customer_name??''); ?>
escape($wo->city??''); ?>
trade); ?>priority); ?>status)); ?>escape($wo->tech_name??'Unassigned'); ?>scheduled_date?date('M j',strtotime($wo->scheduled_date)):'—'; ?>total>0?'$'.number_format((float)$wo->total,2):'—'; ?>
No work orders
+ + diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldEquipmentController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldEquipmentController.php new file mode 100644 index 0000000..881eece --- /dev/null +++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldEquipmentController.php @@ -0,0 +1,178 @@ +getIdentity(); + if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) { + http_response_code(403); + echo json_encode(['error' => 'Access denied.']); + Factory::getApplication()->close(); + } + } + + public function listEquipment(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + + $query = $db->getQuery(true) + ->select('eq.*, loc.name AS location_name, loc.address') + ->select('cd.name AS customer_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'eq')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = eq.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = eq.contact_id') + ->order('eq.name ASC'); + + $type = $input->getString('type', ''); + if ($type) $query->where($db->quoteName('eq.type') . ' = ' . $db->quote($type)); + + $status = $input->getString('status', ''); + if ($status) $query->where($db->quoteName('eq.status') . ' = ' . $db->quote($status)); + + $locationId = $input->getInt('location_id', 0); + if ($locationId) $query->where('eq.location_id = ' . $locationId); + + $db->setQuery($query, 0, 100); + $this->sendJson($db->loadObjectList() ?: []); + } + + public function getEquipment(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $id = Factory::getApplication()->getInput()->getInt('id', 0); + + $db->setQuery($db->getQuery(true) + ->select('eq.*, loc.name AS location_name, loc.address, cd.name AS customer_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'eq')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = eq.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = eq.contact_id') + ->where('eq.id = ' . $id)); + $equipment = $db->loadObject(); + + if (!$equipment) { + http_response_code(404); + $this->sendJson(['error' => 'Equipment not found']); + return; + } + + // Service history + $db->setQuery($db->getQuery(true) + ->select('wo.id, wo.wo_number, wo.description, wo.status, wo.completed_at, wo.trade') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->where('wo.equipment_id = ' . $id) + ->order('wo.scheduled_date DESC'), 0, 20); + $equipment->service_history = $db->loadObjectList() ?: []; + + $this->sendJson($equipment); + } + + public function listVehicles(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('v.*, t_cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_vehicles', 'v')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.technician_id') + ->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id') + ->order('v.vehicle_name ASC')); + + $this->sendJson($db->loadObjectList() ?: []); + } + + public function truckStock(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $vehicleId = Factory::getApplication()->getInput()->getInt('id', 0); + + $db->setQuery($db->getQuery(true) + ->select('ts.*, p.title AS product_name') + ->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts')) + ->join('LEFT', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id') + ->where('ts.vehicle_id = ' . $vehicleId) + ->order('p.title ASC')); + + $this->sendJson($db->loadObjectList() ?: []); + } + + public function listAgreements(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + + $query = $db->getQuery(true) + ->select('sa.*, cd.name AS customer_name, loc.address') + ->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = sa.location_id') + ->order('sa.end_date ASC'); + + $status = $input->getString('status', ''); + if ($status) $query->where($db->quoteName('sa.status') . ' = ' . $db->quote($status)); + + $db->setQuery($query, 0, 100); + $this->sendJson($db->loadObjectList() ?: []); + } + + public function getAgreement(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $id = Factory::getApplication()->getInput()->getInt('id', 0); + + $db->setQuery($db->getQuery(true) + ->select('sa.*, cd.name AS customer_name, loc.name AS location_name, loc.address') + ->from($db->quoteName('#__mokosuitefield_service_agreements', 'sa')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = sa.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = sa.location_id') + ->where('sa.id = ' . $id)); + $agreement = $db->loadObject(); + + if (!$agreement) { + http_response_code(404); + $this->sendJson(['error' => 'Agreement not found']); + return; + } + + // Work orders under this agreement + $db->setQuery($db->getQuery(true) + ->select('wo.id, wo.wo_number, wo.description, wo.status, wo.scheduled_date, wo.trade') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->where('wo.agreement_id = ' . $id) + ->order('wo.scheduled_date DESC'), 0, 30); + $agreement->work_orders = $db->loadObjectList() ?: []; + + $this->sendJson($agreement); + } + + private function sendJson(mixed $data): void + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + Factory::getApplication()->close(); + } +} diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldEstimatesController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldEstimatesController.php new file mode 100644 index 0000000..5e598c9 --- /dev/null +++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldEstimatesController.php @@ -0,0 +1,150 @@ +getIdentity(); + if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) { + http_response_code(403); + echo json_encode(['error' => 'Access denied.']); + Factory::getApplication()->close(); + } + } + + public function listEstimates(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + + $query = $db->getQuery(true) + ->select('e.*, cd.name AS customer_name, loc.address') + ->from($db->quoteName('#__mokosuitefield_estimates', 'e')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id') + ->order('e.created DESC'); + + $status = $input->getString('status', ''); + if ($status) $query->where($db->quoteName('e.status') . ' = ' . $db->quote($status)); + + $techId = $input->getInt('technician_id', 0); + if ($techId) $query->where('e.technician_id = ' . $techId); + + $db->setQuery($query, 0, 100); + $this->sendJson($db->loadObjectList() ?: []); + } + + public function createEstimate(): void + { + $this->requireAuth('core.create'); + $input = Factory::getApplication()->getInput(); + + $estimateId = \Moko\Plugin\System\MokoSuiteField\Helper\EstimateHelper::createEstimate( + $input->getInt('contact_id', 0), + $input->getInt('location_id', 0), + $input->getString('trade', 'general'), + $input->getString('description', ''), + json_decode($input->getString('line_items', '[]'), true) ?: [] + ); + + $this->sendJson(['success' => true, 'estimate_id' => $estimateId]); + } + + public function updateStatus(): void + { + $this->requireAuth('core.edit'); + $input = Factory::getApplication()->getInput(); + $id = $input->getInt('id', 0); + $status = $input->getString('status', ''); + + if (!in_array($status, ['sent', 'approved', 'rejected', 'expired'])) { + http_response_code(400); + $this->sendJson(['error' => 'Invalid status']); + return; + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + $update = (object) [ + 'id' => $id, + 'status' => $status, + ]; + + if ($status === 'approved') { + $update->approved_at = Factory::getDate()->toSql(); + } + + $db->updateObject('#__mokosuitefield_estimates', $update, 'id'); + $this->sendJson(['success' => true]); + } + + public function convertToWorkOrder(): void + { + $this->requireAuth('core.create'); + $id = Factory::getApplication()->getInput()->getInt('id', 0); + + $woId = \Moko\Plugin\System\MokoSuiteField\Helper\EstimateHelper::convertToWorkOrder($id); + + if (!$woId) { + http_response_code(400); + $this->sendJson(['error' => 'Could not convert estimate']); + return; + } + + $this->sendJson(['success' => true, 'work_order_id' => $woId]); + } + + public function getRoute(): void + { + $this->requireAuth('core.manage'); + $techId = Factory::getApplication()->getInput()->getInt('tech_id', 0); + $date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d')); + + $route = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::getTechRoute($techId, $date); + $metrics = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::estimateRouteMetrics($techId, $date); + + $this->sendJson([ + 'route' => $route, + 'metrics' => $metrics, + ]); + } + + public function optimizeRoute(): void + { + $this->requireAuth('core.manage'); + $techId = Factory::getApplication()->getInput()->getInt('tech_id', 0); + $date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d')); + + $optimized = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::optimizeRoute($techId, $date); + $metrics = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::estimateRouteMetrics($techId, $date); + + $this->sendJson([ + 'route' => $optimized, + 'metrics' => $metrics, + ]); + } + + private function sendJson(mixed $data): void + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + Factory::getApplication()->close(); + } +} diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldMobileController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldMobileController.php new file mode 100644 index 0000000..f433e4c --- /dev/null +++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldMobileController.php @@ -0,0 +1,252 @@ +getIdentity(); + if (!$user || $user->guest) { + http_response_code(401); + echo json_encode(['error' => 'Authentication required.']); + Factory::getApplication()->close(); + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true) + ->select('t.*, cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('INNER', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->where('cd.user_id = ' . (int) $user->id)); + $tech = $db->loadObject(); + + if (!$tech) { + http_response_code(403); + echo json_encode(['error' => 'No technician profile.']); + Factory::getApplication()->close(); + } + + return $tech; + } + + public function myJobs(): void + { + $tech = $this->requireTech(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $today = date('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, cd.telephone AS customer_phone') + ->select('loc.address, loc.city, loc.state, loc.zip, loc.latitude, loc.longitude, loc.access_notes') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->where('wo.technician_id = ' . (int) $tech->id) + ->where('(wo.scheduled_date = ' . $db->quote($today) . ' OR wo.status IN (' . $db->quote('dispatched') . ',' . $db->quote('en_route') . ',' . $db->quote('on_site') . ',' . $db->quote('in_progress') . '))') + ->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ') ASC')); + + $this->sendJson($db->loadObjectList() ?: []); + } + + public function updateStatus(): void + { + $tech = $this->requireTech(); + $input = Factory::getApplication()->getInput(); + + \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::updateStatus( + $input->getInt('work_order_id', 0), + $input->getString('status', ''), + $input->getFloat('lat', 0) ?: null, + $input->getFloat('lng', 0) ?: null + ); + + $this->sendJson(['message' => 'Status updated.']); + } + + public function uploadPhoto(): void + { + $tech = $this->requireTech(); + $input = Factory::getApplication()->getInput(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $woId = $input->getInt('work_order_id', 0); + $photoType = $input->getString('photo_type', 'other'); + $caption = $input->getString('caption', ''); + $lat = $input->getFloat('lat', 0) ?: null; + $lng = $input->getFloat('lng', 0) ?: null; + + // Handle file upload + $file = $input->files->get('photo'); + if (!$file || $file['error'] !== 0) { + http_response_code(400); + $this->sendJson(['error' => 'No photo uploaded.']); + return; + } + + $uploadDir = 'media/com_mokosuitefield/photos/' . date('Y/m/'); + if (!is_dir(JPATH_ROOT . '/' . $uploadDir)) { + mkdir(JPATH_ROOT . '/' . $uploadDir, 0755, true); + } + + $ext = pathinfo($file['name'], PATHINFO_EXTENSION); + $filename = 'wo' . $woId . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext; + $filePath = $uploadDir . $filename; + + move_uploaded_file($file['tmp_name'], JPATH_ROOT . '/' . $filePath); + + $db->insertObject('#__mokosuitefield_wo_photos', (object) [ + 'work_order_id' => $woId, + 'file_path' => $filePath, + 'photo_type' => $photoType, + 'caption' => $caption, + 'latitude' => $lat, + 'longitude' => $lng, + 'taken_at' => Factory::getDate()->toSql(), + 'uploaded_by' => Factory::getApplication()->getIdentity()->id, + 'created' => Factory::getDate()->toSql(), + ], 'id'); + + $this->sendJson(['message' => 'Photo uploaded.', 'path' => $filePath]); + } + + public function startTime(): void + { + $tech = $this->requireTech(); + $input = Factory::getApplication()->getInput(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->insertObject('#__mokosuitefield_time_entries', (object) [ + 'work_order_id' => $input->getInt('work_order_id', 0), + 'technician_id' => $tech->id, + 'start_time' => Factory::getDate()->toSql(), + 'is_travel' => $input->getInt('is_travel', 0), + 'rate' => $tech->hourly_rate, + 'created' => Factory::getDate()->toSql(), + ], 'id'); + + $this->sendJson(['message' => 'Timer started.']); + } + + public function stopTime(): void + { + $tech = $this->requireTech(); + $input = Factory::getApplication()->getInput(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $entryId = $input->getInt('entry_id', 0); + $now = Factory::getDate()->toSql(); + + $db->setQuery($db->getQuery(true)->select('start_time, rate')->from('#__mokosuitefield_time_entries')->where('id = ' . $entryId)); + $entry = $db->loadObject(); + + if (!$entry) { + http_response_code(404); + $this->sendJson(['error' => 'Time entry not found.']); + return; + } + + $hours = round((strtotime($now) - strtotime($entry->start_time)) / 3600, 2); + + $db->updateObject('#__mokosuitefield_time_entries', (object) [ + 'id' => $entryId, + 'end_time' => $now, + 'hours' => $hours, + ], 'id'); + + $this->sendJson(['message' => 'Timer stopped.', 'hours' => $hours]); + } + + public function logPart(): void + { + $tech = $this->requireTech(); + $input = Factory::getApplication()->getInput(); + + $productId = $input->getInt('product_id', 0); + $qty = $input->getFloat('quantity', 1); + $woId = $input->getInt('work_order_id', 0); + + // Deduct from truck stock + \Moko\Plugin\System\MokoSuiteField\Helper\TruckStockHelper::usePart( + (int) $tech->vehicle_id, $productId, $qty + ); + + // Add as WO line item + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true)->select('name, cost_price, price')->from('#__mokosuite_crm_products')->where('id = ' . $productId)); + $product = $db->loadObject(); + + if ($product) { + $db->insertObject('#__mokosuitefield_wo_items', (object) [ + 'work_order_id' => $woId, + 'item_type' => 'part', + 'product_id' => $productId, + 'description' => $product->name, + 'quantity' => $qty, + 'unit_price' => (float) $product->price, + 'line_total' => $qty * (float) $product->price, + 'from_truck_stock' => 1, + ]); + } + + $this->sendJson(['message' => 'Part logged.']); + } + + public function gpsHeartbeat(): void + { + $tech = $this->requireTech(); + $input = Factory::getApplication()->getInput(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->updateObject('#__mokosuitefield_technicians', (object) [ + 'id' => $tech->id, + 'current_lat' => $input->getFloat('lat', 0), + 'current_lng' => $input->getFloat('lng', 0), + 'last_location_update' => Factory::getDate()->toSql(), + ], 'id'); + + $this->sendJson(['message' => 'Location updated.']); + } + + public function equipmentLookup(): void + { + $this->requireTech(); + $qr = Factory::getApplication()->getInput()->getString('qr', ''); + + $equipment = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getByQrCode($qr); + + if (!$equipment) { + http_response_code(404); + $this->sendJson(['error' => 'Equipment not found.']); + return; + } + + $this->sendJson($equipment); + } + + private function sendJson(mixed $data): void + { + $app = Factory::getApplication(); + $app->getDocument()->setMimeEncoding('application/json'); + echo json_encode(['data' => $data], JSON_THROW_ON_ERROR); + $app->close(); + } +} diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldReportsController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldReportsController.php new file mode 100644 index 0000000..e987004 --- /dev/null +++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldReportsController.php @@ -0,0 +1,122 @@ +getIdentity(); + if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise('core.manage', 'com_mokosuitefield'))) { + http_response_code(403); + echo json_encode(['error' => 'Access denied']); + Factory::getApplication()->close(); + } + } + + public function techPerformance(): void + { + $this->requireAuth(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + $from = $input->getString('from', date('Y-m-01')); + $to = $input->getString('to', date('Y-m-d')); + + $db->setQuery($db->getQuery(true) + ->select('t.id, cd.name AS tech_name, t.trade') + ->select('COUNT(wo.id) AS total_jobs') + ->select('SUM(CASE WHEN wo.status = ' . $db->quote('completed') . ' THEN 1 ELSE 0 END) AS completed') + ->select('COALESCE(AVG(TIMESTAMPDIFF(MINUTE, wo.dispatched_at, wo.completed_at)), 0) AS avg_resolution_min') + ->select('COALESCE(SUM(te.hours), 0) AS total_hours') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_work_orders', 'wo') . ' ON wo.technician_id = t.id AND wo.scheduled_date BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)) + ->join('LEFT', $db->quoteName('#__mokosuitefield_time_entries', 'te') . ' ON te.wo_id = wo.id') + ->group('t.id') + ->order('completed DESC')); + + $techs = $db->loadObjectList() ?: []; + + foreach ($techs as &$tech) { + $tech->completion_rate = (int) $tech->total_jobs > 0 + ? round((int) $tech->completed / (int) $tech->total_jobs * 100, 1) : 0; + } + + $this->sendJson($techs); + } + + public function revenueByTrade(): void + { + $this->requireAuth(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + $from = $input->getString('from', date('Y-m-01')); + $to = $input->getString('to', date('Y-m-d')); + + $db->setQuery($db->getQuery(true) + ->select('wo.trade') + ->select('COUNT(wo.id) AS job_count') + ->select('COALESCE(SUM(i.total), 0) AS revenue') + ->select('COALESCE(AVG(i.total), 0) AS avg_invoice') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__mokosuite_crm_invoices', 'i') . ' ON i.id = wo.invoice_id') + ->where('wo.scheduled_date BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)) + ->where($db->quoteName('wo.status') . ' = ' . $db->quote('completed')) + ->group('wo.trade') + ->order('revenue DESC')); + + $this->sendJson($db->loadObjectList() ?: []); + } + + public function partsUsage(): void + { + $this->requireAuth(); + $parts = \Moko\Plugin\System\MokoSuiteField\Helper\PartsHelper::getCommonParts('', 30); + $lowStock = \Moko\Plugin\System\MokoSuiteField\Helper\PartsHelper::getLowStockParts(20); + + $this->sendJson(['top_used' => $parts, 'low_stock' => $lowStock]); + } + + public function slaCompliance(): void + { + $this->requireAuth(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + $from = $input->getString('from', date('Y-m-01')); + $to = $input->getString('to', date('Y-m-d')); + $slaHours = $input->getInt('sla_hours', 24); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total') + ->select('SUM(CASE WHEN TIMESTAMPDIFF(HOUR, wo.created, wo.completed_at) <= ' . (int) $slaHours . ' THEN 1 ELSE 0 END) AS within_sla') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->where($db->quoteName('wo.status') . ' = ' . $db->quote('completed')) + ->where('wo.scheduled_date BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to))); + + $stats = $db->loadObject() ?: (object) ['total' => 0, 'within_sla' => 0]; + $stats->sla_pct = (int) $stats->total > 0 ? round((int) $stats->within_sla / (int) $stats->total * 100, 1) : 0; + $stats->sla_target_hours = $slaHours; + + $this->sendJson($stats); + } + + private function sendJson(mixed $data): void + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + Factory::getApplication()->close(); + } +} diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldSchedulingController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldSchedulingController.php new file mode 100644 index 0000000..75b125c --- /dev/null +++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldSchedulingController.php @@ -0,0 +1,83 @@ +getIdentity(); + if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) { + http_response_code(403); + echo json_encode(['error' => 'Access denied']); + Factory::getApplication()->close(); + } + } + + public function availableSlots(): void + { + $this->requireAuth(); + $input = Factory::getApplication()->getInput(); + + $slots = \Moko\Plugin\System\MokoSuiteField\Helper\SchedulingHelper::getAvailableSlots( + $input->getString('date', date('Y-m-d')), + $input->getString('trade', 'general'), + $input->getInt('duration', 60) + ); + + $this->sendJson($slots); + } + + public function bookSlot(): void + { + $this->requireAuth('core.create'); + $input = Factory::getApplication()->getInput(); + + $result = \Moko\Plugin\System\MokoSuiteField\Helper\SchedulingHelper::scheduleWorkOrder( + $input->getInt('wo_id', 0), + $input->getString('date', ''), + $input->getString('time', ''), + $input->getInt('tech_id', 0) ?: null + ); + + $this->sendJson(['success' => $result]); + } + + public function todaySchedule(): void + { + $this->requireAuth(); + $schedule = \Moko\Plugin\System\MokoSuiteField\Helper\SchedulingHelper::getTodaySchedule(); + $this->sendJson($schedule); + } + + public function techRoute(): void + { + $this->requireAuth(); + $techId = Factory::getApplication()->getInput()->getInt('tech_id', 0); + $date = Factory::getApplication()->getInput()->getString('date', date('Y-m-d')); + + $route = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::getTechRoute($techId, $date); + $metrics = \Moko\Plugin\System\MokoSuiteField\Helper\RouteHelper::estimateRouteMetrics($techId, $date); + + $this->sendJson(['route' => $route, 'metrics' => $metrics]); + } + + private function sendJson(mixed $data): void + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + Factory::getApplication()->close(); + } +} diff --git a/source/packages/com_mokosuitefield/api/src/Controller/FieldWorkOrderController.php b/source/packages/com_mokosuitefield/api/src/Controller/FieldWorkOrderController.php new file mode 100644 index 0000000..d8fbe4c --- /dev/null +++ b/source/packages/com_mokosuitefield/api/src/Controller/FieldWorkOrderController.php @@ -0,0 +1,131 @@ +getIdentity(); + if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitefield'))) { + http_response_code(403); + echo json_encode(['error' => 'Access denied.']); + Factory::getApplication()->close(); + } + } + + public function list(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + $status = $input->getString('status', ''); + $techId = $input->getInt('technician_id', 0); + $date = $input->getString('date', ''); + + $query = $db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, loc.address, loc.city, t_cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id') + ->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id') + ->order('wo.scheduled_date ASC, wo.scheduled_time_start ASC'); + + if ($status) $query->where($db->quoteName('wo.status') . ' = ' . $db->quote($status)); + if ($techId) $query->where('wo.technician_id = ' . $techId); + if ($date) $query->where('wo.scheduled_date = ' . $db->quote($date)); + + $db->setQuery($query, 0, 100); + $this->sendJson($db->loadObjectList() ?: []); + } + + public function create(): void + { + $this->requireAuth('core.create'); + $input = Factory::getApplication()->getInput(); + + $woId = \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::create( + $input->getInt('contact_id', 0), + $input->getString('trade', 'general'), + $input->getString('description', ''), + [ + 'location_id' => $input->getInt('location_id', 0), + 'priority' => $input->getString('priority', 'normal'), + 'category' => $input->getString('category', ''), + 'scheduled_date' => $input->getString('scheduled_date', ''), + 'time_start' => $input->getString('time_start', ''), + 'source' => $input->getString('source', 'phone'), + ] + ); + + $this->sendJson(['id' => $woId, 'message' => 'Work order created.']); + } + + public function updateStatus(): void + { + $input = Factory::getApplication()->getInput(); + + \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::updateStatus( + $input->getInt('id', 0), + $input->getString('status', ''), + $input->getFloat('lat', 0) ?: null, + $input->getFloat('lng', 0) ?: null + ); + + $this->sendJson(['message' => 'Status updated.']); + } + + public function dispatchToTech(): void + { + $this->requireAuth('field.dispatch'); + $input = Factory::getApplication()->getInput(); + + \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::dispatch( + $input->getInt('work_order_id', 0), + $input->getInt('technician_id', 0) + ); + + $this->sendJson(['message' => 'Dispatched.']); + } + + public function board(): void + { + $date = Factory::getApplication()->getInput()->getString('date', ''); + $this->sendJson(\Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getDispatchBoard($date)); + } + + public function availableTechs(): void + { + $input = Factory::getApplication()->getInput(); + $tech = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::findBestTech( + $input->getString('trade', 'general'), + $input->getString('zip', '') + ); + + $this->sendJson($tech ? [$tech] : []); + } + + private function sendJson(mixed $data): void + { + $app = Factory::getApplication(); + $app->getDocument()->setMimeEncoding('application/json'); + echo json_encode(['data' => $data], JSON_THROW_ON_ERROR); + $app->close(); + } +} diff --git a/source/packages/com_mokosuitefield/media/css/field.css b/source/packages/com_mokosuitefield/media/css/field.css new file mode 100644 index 0000000..a22318a --- /dev/null +++ b/source/packages/com_mokosuitefield/media/css/field.css @@ -0,0 +1,9 @@ +/* MokoSuite Field Service Styles */ +.dispatch-board .tech-card { border-left: 4px solid #198754; } +.dispatch-board .tech-card.dispatched { border-left-color: #0d6efd; } +.dispatch-board .tech-card.on-site { border-left-color: #ffc107; } +.wo-priority-emergency { background-color: rgba(220, 53, 69, 0.1) !important; } +.wo-priority-urgent { background-color: rgba(255, 193, 7, 0.08) !important; } +.tech-mobile .current-job { border: 2px solid #0d6efd; } +@media (max-width: 768px) { .tech-mobile .card { margin-bottom: 0.5rem; } } +@media print { .btn, .toolbar { display: none !important; } } diff --git a/source/packages/com_mokosuitefield/media/js/dispatch.js b/source/packages/com_mokosuitefield/media/js/dispatch.js new file mode 100644 index 0000000..f0d9d81 --- /dev/null +++ b/source/packages/com_mokosuitefield/media/js/dispatch.js @@ -0,0 +1,6 @@ +document.addEventListener('DOMContentLoaded', function() { + // Auto-refresh dispatch board every 30 seconds + if (document.querySelector('.dispatch-board')) { + setInterval(function() { location.reload(); }, 30000); + } +}); diff --git a/source/packages/com_mokosuitefield/mokosuitefield.xml b/source/packages/com_mokosuitefield/mokosuitefield.xml deleted file mode 100644 index 6574c50..0000000 --- a/source/packages/com_mokosuitefield/mokosuitefield.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - MokoSuite Field - Moko Consulting - 2026-06-27 - Copyright (C) 2026 Moko Consulting. - GPL-3.0-or-later - 06.00.25 - Moko\Component\MokoSuiteField - - servicessrctmpl - COM_MOKOSUITEFIELD_SHORT - - diff --git a/source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php b/source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php new file mode 100644 index 0000000..9ca2166 --- /dev/null +++ b/source/packages/com_mokosuitefield/site/src/Controller/DisplayController.php @@ -0,0 +1,8 @@ +getParams('com_mokosuitefield'); + $this->companyName = $params->get('company_name', $app->get('sitename')); + + $this->trades = [ + 'plumbing' => 'Plumbing', + 'electrical' => 'Electrical', + 'hvac' => 'HVAC / Heating & Cooling', + 'appliance' => 'Appliance Repair', + 'general' => 'General Maintenance', + 'carpentry' => 'Carpentry', + 'painting' => 'Painting', + 'roofing' => 'Roofing', + 'landscaping' => 'Landscaping', + 'locksmith' => 'Locksmith', + ]; + + if ($app->getInput()->getMethod() === 'POST' && \Joomla\CMS\Session\Session::checkToken()) { + $input = $app->getInput(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $name = $input->getString('name', ''); + $email = $input->getString('email', ''); + $phone = $input->getString('phone', ''); + $address = $input->getString('address', ''); + $trade = $input->getString('trade', 'general'); + $desc = $input->getString('description', ''); + $priority = $input->getString('priority', 'normal'); + + if ($name && $phone && $desc) { + // Find or create contact + $db->setQuery($db->getQuery(true)->select('id')->from('#__contact_details') + ->where($db->quoteName('telephone') . ' = ' . $db->quote($phone))); + $contactId = (int) $db->loadResult(); + + if (!$contactId) { + $db->insertObject('#__contact_details', (object) [ + 'name' => $name, 'email_to' => $email, 'telephone' => $phone, + 'address' => $address, 'published' => 1, 'created' => Factory::getDate()->toSql(), + ], 'id'); + $contactId = $db->insertid(); + } + + // Create work order + \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::create( + (int) $contactId, $trade, $desc, [ + 'priority' => $priority, + 'source' => 'website', + ] + ); + + $this->submitted = true; + } + } + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/site/src/View/CustomerPortal/HtmlView.php b/source/packages/com_mokosuitefield/site/src/View/CustomerPortal/HtmlView.php new file mode 100644 index 0000000..1aef77c --- /dev/null +++ b/source/packages/com_mokosuitefield/site/src/View/CustomerPortal/HtmlView.php @@ -0,0 +1,89 @@ +getIdentity(); + + if (!$user || $user->guest) { + $app->redirect('index.php?option=com_users&view=login'); + return; + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Resolve contact from Joomla user + $db->setQuery($db->getQuery(true)->select('id')->from('#__contact_details')->where('user_id = ' . (int) $user->id)); + $this->contactId = (int) $db->loadResult(); + + if (!$this->contactId) { + $app->enqueueMessage('No customer profile found.', 'warning'); + return; + } + + // Active work orders (in-progress) + $db->setQuery($db->getQuery(true) + ->select('wo.*, t_cd.name AS tech_name, t.trade AS tech_trade, loc.address') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.technician_id') + ->join('LEFT', $db->quoteName('#__contact_details', 't_cd') . ' ON t_cd.id = t.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->where('wo.contact_id = ' . $this->contactId) + ->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('completed') . ',' . $db->quote('invoiced') . ',' . $db->quote('cancelled') . ')') + ->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ') ASC')); + $this->activeOrders = $db->loadObjectList() ?: []; + + // Recent completed orders (last 10) + $db->setQuery($db->getQuery(true) + ->select('wo.wo_number, wo.trade, wo.category, wo.total, wo.actual_departure AS completed_at, wo.work_performed') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->where('wo.contact_id = ' . $this->contactId) + ->where($db->quoteName('wo.status') . ' IN (' . $db->quote('completed') . ',' . $db->quote('invoiced') . ')') + ->order('wo.actual_departure DESC'), 0, 10); + $this->orderHistory = $db->loadObjectList() ?: []; + + // Equipment at customer locations + $db->setQuery($db->getQuery(true) + ->select('e.*, loc.address') + ->from($db->quoteName('#__mokosuitefield_equipment', 'e')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id') + ->where('e.contact_id = ' . $this->contactId) + ->order('e.equipment_type ASC')); + $this->equipment = $db->loadObjectList() ?: []; + + // Active service agreements + $this->agreements = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getActiveAgreements($this->contactId); + + // Next scheduled service + $db->setQuery($db->getQuery(true) + ->select('wo.wo_number, wo.scheduled_date, wo.scheduled_time_start, wo.trade, wo.category') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->where('wo.contact_id = ' . $this->contactId) + ->where($db->quoteName('wo.scheduled_date') . ' >= CURDATE()') + ->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ')') + ->order('wo.scheduled_date ASC, wo.scheduled_time_start ASC'), 0, 1); + $this->nextService = $db->loadObject(); + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/site/src/View/EstimateView/HtmlView.php b/source/packages/com_mokosuitefield/site/src/View/EstimateView/HtmlView.php new file mode 100644 index 0000000..6c6f8eb --- /dev/null +++ b/source/packages/com_mokosuitefield/site/src/View/EstimateView/HtmlView.php @@ -0,0 +1,90 @@ +getInput(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $token = $input->getString('token', ''); + + if (!$token) { + $app->enqueueMessage('Invalid estimate link.', 'warning'); + parent::display($tpl); + return; + } + + // Load estimate by token + $db->setQuery($db->getQuery(true) + ->select('e.*, cd.name AS customer_name, cd.email_to, cd.telephone') + ->select('l.address, l.city, l.state, l.zip') + ->from($db->quoteName('#__mokosuitefield_estimates', 'e')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'l') . ' ON l.id = e.location_id') + ->where($db->quoteName('e.approval_token') . ' = ' . $db->quote($token))); + $this->estimate = $db->loadObject(); + + if (!$this->estimate) { + $app->enqueueMessage('Estimate not found or link expired.', 'warning'); + parent::display($tpl); + return; + } + + // Load line items + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitefield_estimate_items') + ->where('estimate_id = ' . (int) $this->estimate->id) + ->order('ordering ASC')); + $this->lineItems = $db->loadObjectList() ?: []; + + // Handle approval/rejection (CSRF check not required — token-based public page) + if ($input->getMethod() === 'POST' && \Joomla\CMS\Session\Session::checkToken()) { + $action = $input->getString('action', ''); + + if ($action === 'approve' && $this->estimate->status === 'sent') { + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitefield_estimates') + ->set($db->quoteName('status') . ' = ' . $db->quote('approved')) + ->set($db->quoteName('approved_at') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->set($db->quoteName('customer_signature') . ' = ' . $db->quote($input->getString('signature', ''))) + ->where('id = ' . (int) $this->estimate->id)); + $db->execute(); + + $this->actioned = true; + $this->actionResult = 'approved'; + $this->estimate->status = 'approved'; + } elseif ($action === 'reject' && $this->estimate->status === 'sent') { + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitefield_estimates') + ->set($db->quoteName('status') . ' = ' . $db->quote('rejected')) + ->set($db->quoteName('rejection_reason') . ' = ' . $db->quote($input->getString('reason', ''))) + ->where('id = ' . (int) $this->estimate->id)); + $db->execute(); + + $this->actioned = true; + $this->actionResult = 'rejected'; + $this->estimate->status = 'rejected'; + } + } + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/site/src/View/TechMobile/HtmlView.php b/source/packages/com_mokosuitefield/site/src/View/TechMobile/HtmlView.php new file mode 100644 index 0000000..7695292 --- /dev/null +++ b/source/packages/com_mokosuitefield/site/src/View/TechMobile/HtmlView.php @@ -0,0 +1,70 @@ +getIdentity(); + + if (!$user || $user->guest) { + $app->redirect('index.php?option=com_users&view=login'); + return; + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Find technician record for logged-in user + $db->setQuery($db->getQuery(true) + ->select('t.*, cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('INNER', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->join('INNER', $db->quoteName('#__users', 'u') . ' ON u.id = ' . (int) $user->id) + ->where('cd.user_id = ' . (int) $user->id)); + $this->tech = $db->loadObject(); + + if (!$this->tech) { + $app->enqueueMessage('No technician profile found for your account.', 'warning'); + return; + } + + $today = date('Y-m-d'); + + // Today's assigned jobs + $db->setQuery($db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, cd.telephone AS customer_phone') + ->select('loc.address, loc.city, loc.state, loc.zip, loc.latitude, loc.longitude, loc.access_notes') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->where('wo.technician_id = ' . (int) $this->tech->id) + ->where('(wo.scheduled_date = ' . $db->quote($today) . ' OR (wo.scheduled_date IS NULL AND wo.status NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ',' . $db->quote('invoiced') . ')))') + ->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ',' . $db->quote('low') . ') ASC, wo.scheduled_time_start ASC')); + $this->todayJobs = $db->loadObjectList() ?: []; + + // Current active job (en_route, on_site, or in_progress) + foreach ($this->todayJobs as $job) { + if (in_array($job->status, ['en_route', 'on_site', 'in_progress'])) { + $this->currentJob = $job; + break; + } + } + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitefield/site/tmpl/bookservice/default.php b/source/packages/com_mokosuitefield/site/tmpl/bookservice/default.php new file mode 100644 index 0000000..237af91 --- /dev/null +++ b/source/packages/com_mokosuitefield/site/tmpl/bookservice/default.php @@ -0,0 +1,33 @@ +companyName; +$trades = $this->trades; +if ($this->submitted) : ?> +

Service Request Received

We will contact you shortly to schedule your appointment.

+ +
+

Request Service

+

escape($company); ?>

+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
diff --git a/source/packages/com_mokosuitefield/site/tmpl/customerportal/default.php b/source/packages/com_mokosuitefield/site/tmpl/customerportal/default.php new file mode 100644 index 0000000..9f7da25 --- /dev/null +++ b/source/packages/com_mokosuitefield/site/tmpl/customerportal/default.php @@ -0,0 +1,14 @@ +contactId) return; +$active=$this->activeOrders;$history=$this->orderHistory;$next=$this->nextService; +?> +

My Service Portal

+
Next Service
trade); ?>scheduled_date)); ?>
+
Active Work Orders
+ +
WO#ServiceStatus
escape($wo->wo_number); ?>trade); ?>status)); ?>
+
Service History
+ +
WO#ServiceTotalDate
escape($h->wo_number); ?>trade); ?>total>0?"$".number_format((float)$h->total,2):""; ?>completed_at?date("M j",strtotime($h->completed_at)):""; ?>
+
diff --git a/source/packages/com_mokosuitefield/site/tmpl/estimateview/default.php b/source/packages/com_mokosuitefield/site/tmpl/estimateview/default.php new file mode 100644 index 0000000..50fa071 --- /dev/null +++ b/source/packages/com_mokosuitefield/site/tmpl/estimateview/default.php @@ -0,0 +1,96 @@ +estimate) return; +$e = $this->estimate; +$subtotal = 0; +foreach ($this->lineItems as $li) { $subtotal += (float) $li->line_total; } +$tax = round($subtotal * 0.07, 2); +$total = $subtotal + $tax; +?> +
+ actioned) : ?> +
+

Estimate actionResult); ?>

+

actionResult === 'approved' ? 'Thank you! We will schedule the work shortly.' : 'The estimate has been declined.'; ?>

+
+ + +
+
+

Service Estimate

+ status); ?> +
+
+
+
+ Customer: customer_name ?? ''); ?>
+ address) : ?>Location: address . ', ' . $e->city . ', ' . $e->state . ' ' . $e->zip); ?>
+
+
+ Estimate #: estimate_number ?? $e->id); ?>
+ Date: created)); ?>
+ valid_until) : ?>Valid Until: valid_until)); ?>
+
+
+ + description) : ?> +

Scope of Work: description)); ?>

+ + + + + + lineItems as $li) : ?> + + + + + + + + + + + + + +
DescriptionQtyUnit PriceTotal
description); ?>quantity; ?>$unit_price, 2); ?>$line_total, 2); ?>
Subtotal$
Tax$
Total$
+
+
+ + status === 'sent') : ?> +
+
+
+ + +
+
+
Approve Estimate
+
+ + +
+ +
+
+
+
+
+
+ + +
+
+
Decline Estimate
+
+ + +
+ +
+
+
+
+
+ +
diff --git a/source/packages/com_mokosuitefield/site/tmpl/techmobile/default.php b/source/packages/com_mokosuitefield/site/tmpl/techmobile/default.php new file mode 100644 index 0000000..691ee15 --- /dev/null +++ b/source/packages/com_mokosuitefield/site/tmpl/techmobile/default.php @@ -0,0 +1,56 @@ +tech; +$jobs = $this->todayJobs; +$current = $this->currentJob; +if (!$tech) return; +$statusColors = ['new'=>'secondary','dispatched'=>'info','en_route'=>'warning','on_site'=>'primary','in_progress'=>'primary','parts_needed'=>'danger','completed'=>'success']; +?> +
+
+

escape($tech->tech_name); ?>

+status)); ?> +
+ + +
+
Current Job: escape($current->wo_number); ?>
+
+
escape($current->customer_name); ?>
+

escape($current->address); ?>, escape($current->city); ?>

+customer_phone) : ?>

customer_phone; ?>

+access_notes) : ?>
Access: escape($current->access_notes); ?>
+

escape($current->description); ?>

+
+status === 'dispatched') : ?> +status === 'en_route') : ?> +status === 'on_site') : ?> +status === 'in_progress') : ?> +latitude && $current->longitude) : ?> + + +
+
+
+ + +
Today ( jobs)
+id === $current->id) continue; ?> +
+
+
escape($job->customer_name); ?>status)); ?>
+escape($job->city??''); ?> | scheduled_time_start?date('g:ia',strtotime($job->scheduled_time_start)):'TBD'; ?> | trade); ?> +
+
+ +

No jobs today.

+
+ diff --git a/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini b/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini index 4ca8379..c507f86 100644 --- a/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini +++ b/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini @@ -1,2 +1,2 @@ PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field" -PLG_SYSTEM_MOKOSUITEFIELD_DESC="Work orders, technician dispatch, equipment registry, parts inventory, PM agreements" +PLG_SYSTEM_MOKOSUITEFIELD_DESC="MokoSuite Field Service system plugin - database schema and helpers." diff --git a/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini b/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini index 4ca8379..26bf5c4 100644 --- a/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini +++ b/source/packages/plg_system_mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini @@ -1,2 +1,2 @@ PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field" -PLG_SYSTEM_MOKOSUITEFIELD_DESC="Work orders, technician dispatch, equipment registry, parts inventory, PM agreements" +PLG_SYSTEM_MOKOSUITEFIELD_DESC="MokoSuite Field Service system plugin." diff --git a/source/packages/plg_system_mokosuitefield/mokosuitefield.xml b/source/packages/plg_system_mokosuitefield/mokosuitefield.xml deleted file mode 100644 index e2346ee..0000000 --- a/source/packages/plg_system_mokosuitefield/mokosuitefield.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - System - MokoSuite Field - mokosuitefield - Moko Consulting - 2026-06-27 - Copyright (C) 2026 Moko Consulting. All rights reserved. - GPL-3.0-or-later - hello@mokoconsulting.tech - https://mokoconsulting.tech - 06.00.25 - 8.3 - PLG_SYSTEM_MOKOSUITEFIELD_DESC - Moko\Plugin\System\MokoSuiteField - - src - services - language - sql - - - en-GB/plg_system_mokosuitefield.ini - en-GB/plg_system_mokosuitefield.sys.ini - - sql/install.mysql.sql - sql/uninstall.mysql.sql - - -
- - - - - - - -
-
- - - - - - - - - -
-
- - - -
-
- - - - - - -
-
-
-
diff --git a/source/packages/plg_system_mokosuitefield/services/provider.php b/source/packages/plg_system_mokosuitefield/services/provider.php deleted file mode 100644 index dbcce3a..0000000 --- a/source/packages/plg_system_mokosuitefield/services/provider.php +++ /dev/null @@ -1,33 +0,0 @@ - - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - -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\System\MokoSuiteField\Extension\Field; - -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 Field($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuitefield')); - $plugin->setApplication(Factory::getApplication()); - return $plugin; - } - ); - } -}; diff --git a/source/packages/plg_system_mokosuitefield/sql/install.mysql.sql b/source/packages/plg_system_mokosuitefield/sql/install.mysql.sql index 8aaeac2..e0a9d8d 100644 --- a/source/packages/plg_system_mokosuitefield/sql/install.mysql.sql +++ b/source/packages/plg_system_mokosuitefield/sql/install.mysql.sql @@ -1,191 +1,332 @@ -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_work_orders` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `work_order_ref` VARCHAR(20) NOT NULL, - `customer_contact_id` INT DEFAULT NULL, - `customer_name` VARCHAR(255) NOT NULL, - `customer_phone` VARCHAR(50) NOT NULL DEFAULT '', - `service_address` VARCHAR(500) NOT NULL, - `service_lat` DECIMAL(10,7) DEFAULT NULL, - `service_lng` DECIMAL(10,7) DEFAULT NULL, - `category` ENUM('plumbing','electrical','hvac','appliance','general','emergency','inspection','installation','other') NOT NULL DEFAULT 'general', - `priority` ENUM('emergency','high','medium','low','scheduled') NOT NULL DEFAULT 'medium', - `status` ENUM('requested','scheduled','dispatched','en_route','in_progress','completed','invoiced','cancelled','on_hold') NOT NULL DEFAULT 'requested', - `title` VARCHAR(255) NOT NULL, - `description` TEXT, - `technician_id` INT UNSIGNED DEFAULT NULL, - `pm_agreement_id` INT UNSIGNED DEFAULT NULL, - `scheduled_date` DATE DEFAULT NULL, - `labor_hours` DECIMAL(5,2) NOT NULL DEFAULT 0.00, - `labor_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `parts_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `payment_status` ENUM('pending','partial','paid','void') NOT NULL DEFAULT 'pending', - `customer_signature` TEXT, - `photos_before` JSON DEFAULT NULL, - `photos_after` JSON DEFAULT NULL, - `notes` TEXT, - `created` DATETIME NOT NULL, - `created_by` INT NOT NULL DEFAULT 0, - PRIMARY KEY (`id`), - UNIQUE KEY `idx_ref` (`work_order_ref`), - KEY `idx_customer` (`customer_contact_id`), - KEY `idx_technician` (`technician_id`), - KEY `idx_status` (`status`), - KEY `idx_category` (`category`), - KEY `idx_priority` (`priority`), - KEY `idx_scheduled` (`scheduled_date`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_equipment` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `customer_contact_id` INT DEFAULT NULL, - `service_address` VARCHAR(500) NOT NULL DEFAULT '', - `equipment_type` ENUM('hvac','furnace','ac','water_heater','boiler','plumbing','electrical_panel','generator','appliance','other') NOT NULL DEFAULT 'other', - `brand` VARCHAR(100) NOT NULL DEFAULT '', - `model` VARCHAR(100) NOT NULL DEFAULT '', - `serial_number` VARCHAR(100) NOT NULL DEFAULT '', - `install_date` DATE DEFAULT NULL, - `warranty_expiry` DATE DEFAULT NULL, - `condition_rating` ENUM('excellent','good','fair','poor','replace') NOT NULL DEFAULT 'good', - `last_service_date` DATE DEFAULT NULL, - `next_service_date` DATE DEFAULT NULL, - `notes` TEXT, - `published` TINYINT NOT NULL DEFAULT 1, - `created` DATETIME NOT NULL, - PRIMARY KEY (`id`), - KEY `idx_customer` (`customer_contact_id`), - KEY `idx_type` (`equipment_type`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_equipment_history` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `equipment_id` INT UNSIGNED NOT NULL, - `work_order_id` INT UNSIGNED DEFAULT NULL, - `service_type` ENUM('installation','repair','maintenance','inspection','replacement','warranty') NOT NULL DEFAULT 'maintenance', - `description` TEXT, - `service_date` DATE NOT NULL, - `cost` DECIMAL(10,2) DEFAULT NULL, - `created` DATETIME NOT NULL, - PRIMARY KEY (`id`), - KEY `idx_equipment` (`equipment_id`), - KEY `idx_work_order` (`work_order_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- +-- MokoSuite Field Service Tables +-- +-- ============================================================ +-- Technicians — extends CRM contacts / HRM employees +-- ============================================================ CREATE TABLE IF NOT EXISTS `#__mokosuitefield_technicians` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `contact_id` INT DEFAULT NULL, - `name` VARCHAR(255) NOT NULL, - `email` VARCHAR(255) NOT NULL DEFAULT '', - `phone` VARCHAR(50) NOT NULL DEFAULT '', - `specialties` JSON DEFAULT NULL, - `certifications` VARCHAR(500) NOT NULL DEFAULT '', - `license_number` VARCHAR(100) NOT NULL DEFAULT '', - `hourly_rate` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `status` ENUM('available','on_job','off_duty','vacation','inactive') NOT NULL DEFAULT 'available', + `contact_id` INT NOT NULL COMMENT 'FK to #__contact_details', + `employee_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to HRM employees if installed', + `tech_number` VARCHAR(20) NOT NULL DEFAULT '', + `status` ENUM('available','dispatched','en_route','on_site','off_duty','on_leave') NOT NULL DEFAULT 'available', + `trade` ENUM('general','plumbing','electrical','hvac','appliance','carpentry','painting','roofing','landscaping','pest_control','locksmith','multi_trade') NOT NULL DEFAULT 'general', + `license_number` VARCHAR(100) DEFAULT NULL COMMENT 'Trade license (e.g., master plumber)', + `license_expiry` DATE DEFAULT NULL, + `certifications` JSON DEFAULT NULL COMMENT '["EPA 608","backflow certified","journeyman electrician"]', + `skills` JSON DEFAULT NULL, + `hourly_rate` DECIMAL(10,2) DEFAULT NULL, + `overtime_rate` DECIMAL(10,2) DEFAULT NULL, + `max_daily_jobs` INT UNSIGNED NOT NULL DEFAULT 8, + `service_radius_miles` INT UNSIGNED NOT NULL DEFAULT 30, + `home_zip` VARCHAR(10) DEFAULT NULL, + `home_lat` DECIMAL(10,7) DEFAULT NULL, + `home_lng` DECIMAL(10,7) DEFAULT NULL, `current_lat` DECIMAL(10,7) DEFAULT NULL, `current_lng` DECIMAL(10,7) DEFAULT NULL, - `rating` DECIMAL(3,2) NOT NULL DEFAULT 5.00, - `total_jobs` INT UNSIGNED NOT NULL DEFAULT 0, - `photo` VARCHAR(500) NOT NULL DEFAULT '', - `published` TINYINT NOT NULL DEFAULT 1, + `last_location_update` DATETIME DEFAULT NULL, + `vehicle_id` INT UNSIGNED DEFAULT NULL, + `notes` TEXT, `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_contact` (`contact_id`), + KEY `idx_status` (`status`), + KEY `idx_trade` (`trade`), + KEY `idx_vehicle` (`vehicle_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Service Locations — customer property/site records +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_locations` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `contact_id` INT NOT NULL COMMENT 'FK to CRM contact (property owner)', + `name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'e.g., "Main Office", "123 Oak St Residence"', + `address` TEXT NOT NULL, + `city` VARCHAR(100) NOT NULL DEFAULT '', + `state` VARCHAR(50) NOT NULL DEFAULT '', + `zip` VARCHAR(20) NOT NULL DEFAULT '', + `latitude` DECIMAL(10,7) DEFAULT NULL, + `longitude` DECIMAL(10,7) DEFAULT NULL, + `property_type` ENUM('residential','commercial','industrial','multi_family','government','other') NOT NULL DEFAULT 'residential', + `access_notes` TEXT COMMENT 'Gate codes, parking, pet warnings, key location', + `equipment_notes` TEXT COMMENT 'Known equipment at this location', + `service_history_count` INT UNSIGNED NOT NULL DEFAULT 0, + `last_service_date` DATE DEFAULT NULL, + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_contact` (`contact_id`), - KEY `idx_status` (`status`) + KEY `idx_zip` (`zip`), + KEY `idx_type` (`property_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_parts` ( +-- ============================================================ +-- Work Orders — the core job record +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_work_orders` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `part_number` VARCHAR(50) NOT NULL, - `name` VARCHAR(255) NOT NULL, - `category` VARCHAR(100) NOT NULL DEFAULT '', - `unit_cost` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `retail_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `warehouse_stock` INT NOT NULL DEFAULT 0, - `minimum_stock` INT NOT NULL DEFAULT 0, - `supplier` VARCHAR(255) NOT NULL DEFAULT '', - `published` TINYINT NOT NULL DEFAULT 1, + `wo_number` VARCHAR(30) NOT NULL DEFAULT '', + `contact_id` INT NOT NULL COMMENT 'Customer', + `location_id` INT UNSIGNED DEFAULT NULL, + `technician_id` INT UNSIGNED DEFAULT NULL, + `trade` ENUM('general','plumbing','electrical','hvac','appliance','carpentry','painting','roofing','landscaping','pest_control','locksmith','multi_trade') NOT NULL DEFAULT 'general', + `priority` ENUM('emergency','urgent','high','normal','low','scheduled') NOT NULL DEFAULT 'normal', + `status` ENUM('new','dispatched','en_route','on_site','in_progress','parts_needed','on_hold','completed','invoiced','cancelled') NOT NULL DEFAULT 'new', + `category` VARCHAR(100) DEFAULT NULL COMMENT 'e.g., "water heater", "panel upgrade", "AC repair"', + `description` TEXT NOT NULL, + `customer_po` VARCHAR(100) DEFAULT NULL COMMENT 'Customer PO number', + `scheduled_date` DATE DEFAULT NULL, + `scheduled_time_start` TIME DEFAULT NULL, + `scheduled_time_end` TIME DEFAULT NULL, + `actual_arrival` DATETIME DEFAULT NULL, + `actual_departure` DATETIME DEFAULT NULL, + `work_performed` TEXT, + `diagnosis` TEXT, + `parts_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `labor_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `tax` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `payment_status` ENUM('unpaid','partial','paid','waived') NOT NULL DEFAULT 'unpaid', + `payment_method` VARCHAR(50) DEFAULT NULL, + `customer_signature` TEXT COMMENT 'Base64 signature data', + `customer_signed_at` DATETIME DEFAULT NULL, + `service_agreement_id` INT UNSIGNED DEFAULT NULL, + `invoice_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM invoices', + `deal_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM deals', + `source` ENUM('phone','website','walk_in','referral','service_agreement','emergency','recurring') NOT NULL DEFAULT 'phone', + `created_by` INT NOT NULL DEFAULT 0, `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, PRIMARY KEY (`id`), - UNIQUE KEY `idx_part_number` (`part_number`), - KEY `idx_category` (`category`) + UNIQUE KEY `idx_wo_number` (`wo_number`), + KEY `idx_contact` (`contact_id`), + KEY `idx_location` (`location_id`), + KEY `idx_tech` (`technician_id`), + KEY `idx_status` (`status`), + KEY `idx_priority` (`priority`), + KEY `idx_trade` (`trade`), + KEY `idx_scheduled` (`scheduled_date`), + KEY `idx_agreement` (`service_agreement_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_truck_inventory` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `technician_id` INT UNSIGNED NOT NULL, - `part_id` INT UNSIGNED NOT NULL, - `quantity` INT NOT NULL DEFAULT 0, - `min_quantity` INT NOT NULL DEFAULT 0, - `last_restocked` DATE DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `idx_tech_part` (`technician_id`, `part_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_work_order_parts` ( +-- ============================================================ +-- Work Order Line Items — parts and labor +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_wo_items` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `work_order_id` INT UNSIGNED NOT NULL, - `part_id` INT UNSIGNED NOT NULL, - `quantity` INT UNSIGNED NOT NULL DEFAULT 1, - `unit_price` DECIMAL(10,2) NOT NULL, - `line_total` DECIMAL(10,2) NOT NULL, - `source` ENUM('warehouse','truck','purchased','customer_supplied') NOT NULL DEFAULT 'truck', + `item_type` ENUM('labor','part','material','flat_rate','discount','permit','disposal') NOT NULL DEFAULT 'labor', + `product_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM products for parts', + `description` VARCHAR(500) NOT NULL, + `quantity` DECIMAL(10,2) NOT NULL DEFAULT 1.00, + `unit_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `line_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `taxable` TINYINT NOT NULL DEFAULT 1, + `from_truck_stock` TINYINT NOT NULL DEFAULT 0 COMMENT 'Taken from tech truck inventory', + `sort_order` INT NOT NULL DEFAULT 0, PRIMARY KEY (`id`), - KEY `idx_work_order` (`work_order_id`), - KEY `idx_part` (`part_id`) + KEY `idx_wo` (`work_order_id`), + KEY `idx_type` (`item_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_checklists` ( +-- ============================================================ +-- Work Order Photos — before/after, damage, equipment +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_wo_photos` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `name` VARCHAR(255) NOT NULL, - `category` ENUM('plumbing','electrical','hvac','appliance','general','safety','inspection') NOT NULL DEFAULT 'general', - `items` JSON NOT NULL, - `published` TINYINT NOT NULL DEFAULT 1, - `ordering` INT NOT NULL DEFAULT 0, + `work_order_id` INT UNSIGNED NOT NULL, + `file_path` VARCHAR(500) NOT NULL, + `thumbnail_path` VARCHAR(500) DEFAULT NULL, + `photo_type` ENUM('before','during','after','damage','equipment','permit','other') NOT NULL DEFAULT 'other', + `caption` VARCHAR(500) DEFAULT NULL, + `latitude` DECIMAL(10,7) DEFAULT NULL, + `longitude` DECIMAL(10,7) DEFAULT NULL, + `taken_at` DATETIME DEFAULT NULL, + `uploaded_by` INT NOT NULL DEFAULT 0, `created` DATETIME NOT NULL, PRIMARY KEY (`id`), - KEY `idx_category` (`category`) + KEY `idx_wo` (`work_order_id`), + KEY `idx_type` (`photo_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_pm_agreements` ( +-- ============================================================ +-- Service Agreements — recurring maintenance contracts +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_agreements` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `agreement_ref` VARCHAR(20) NOT NULL, - `customer_contact_id` INT NOT NULL, - `customer_name` VARCHAR(255) NOT NULL, - `service_address` VARCHAR(500) NOT NULL, - `plan_name` VARCHAR(255) NOT NULL, - `status` ENUM('active','expired','cancelled','pending_renewal') NOT NULL DEFAULT 'active', + `contact_id` INT NOT NULL, + `location_id` INT UNSIGNED DEFAULT NULL, + `agreement_number` VARCHAR(30) NOT NULL DEFAULT '', + `title` VARCHAR(255) NOT NULL, + `trade` ENUM('general','plumbing','electrical','hvac','appliance','multi_trade') NOT NULL DEFAULT 'general', + `agreement_type` ENUM('preventive','full_service','parts_only','labor_only','priority_response') NOT NULL DEFAULT 'preventive', + `visits_per_year` INT UNSIGNED NOT NULL DEFAULT 2, + `visits_used` INT UNSIGNED NOT NULL DEFAULT 0, + `annual_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `billing_frequency` ENUM('monthly','quarterly','semi_annual','annual') NOT NULL DEFAULT 'annual', `start_date` DATE NOT NULL, `end_date` DATE DEFAULT NULL, - `visit_frequency` ENUM('monthly','quarterly','semi_annual','annual') NOT NULL DEFAULT 'semi_annual', - `visits_per_year` TINYINT UNSIGNED NOT NULL DEFAULT 2, - `visits_completed` TINYINT UNSIGNED NOT NULL DEFAULT 0, - `annual_cost` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `auto_renew` TINYINT NOT NULL DEFAULT 0, + `auto_renew` TINYINT NOT NULL DEFAULT 1, + `sla_response_hours` INT UNSIGNED DEFAULT NULL COMMENT 'Guaranteed response time', + `parts_discount_pct` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `labor_discount_pct` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `status` ENUM('active','expired','cancelled','pending_renewal') NOT NULL DEFAULT 'active', + `equipment_covered` JSON DEFAULT NULL COMMENT 'List of equipment IDs covered', `notes` TEXT, `created` DATETIME NOT NULL, - `created_by` INT NOT NULL DEFAULT 0, + `modified` DATETIME DEFAULT NULL, PRIMARY KEY (`id`), - UNIQUE KEY `idx_ref` (`agreement_ref`), - KEY `idx_customer` (`customer_contact_id`), + UNIQUE KEY `idx_number` (`agreement_number`), + KEY `idx_contact` (`contact_id`), + KEY `idx_location` (`location_id`), + KEY `idx_status` (`status`), + KEY `idx_end` (`end_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Equipment — customer equipment tracked for service history +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_equipment` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `location_id` INT UNSIGNED NOT NULL, + `contact_id` INT NOT NULL, + `equipment_type` ENUM('water_heater','furnace','ac_unit','heat_pump','boiler','electrical_panel','generator','sump_pump','water_softener','tankless_heater','mini_split','rooftop_unit','compressor','chiller','other') NOT NULL DEFAULT 'other', + `make` VARCHAR(100) DEFAULT NULL, + `model` VARCHAR(100) DEFAULT NULL, + `serial_number` VARCHAR(100) DEFAULT NULL, + `install_date` DATE DEFAULT NULL, + `warranty_expiry` DATE DEFAULT NULL, + `last_service_date` DATE DEFAULT NULL, + `next_service_date` DATE DEFAULT NULL, + `condition` ENUM('excellent','good','fair','poor','needs_replacement') DEFAULT NULL, + `location_detail` VARCHAR(255) DEFAULT NULL COMMENT 'e.g., "basement", "roof", "garage"', + `refrigerant_type` VARCHAR(20) DEFAULT NULL COMMENT 'HVAC: R-410A, R-22, etc.', + `capacity` VARCHAR(50) DEFAULT NULL COMMENT 'e.g., "50 gal", "3 ton", "200 amp"', + `fuel_type` VARCHAR(20) DEFAULT NULL COMMENT 'gas, electric, oil, propane', + `notes` TEXT, + `photo_path` VARCHAR(500) DEFAULT NULL, + `qr_code` VARCHAR(100) DEFAULT NULL COMMENT 'QR code on equipment sticker for quick lookup', + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_location` (`location_id`), + KEY `idx_contact` (`contact_id`), + KEY `idx_type` (`equipment_type`), + KEY `idx_serial` (`serial_number`), + KEY `idx_qr` (`qr_code`), + KEY `idx_next_service` (`next_service_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Vehicles — service fleet tracking +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_vehicles` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `vehicle_number` VARCHAR(20) NOT NULL, + `make` VARCHAR(50) DEFAULT NULL, + `model` VARCHAR(50) DEFAULT NULL, + `year` INT UNSIGNED DEFAULT NULL, + `vin` VARCHAR(17) DEFAULT NULL, + `license_plate` VARCHAR(20) DEFAULT NULL, + `assigned_tech_id` INT UNSIGNED DEFAULT NULL, + `status` ENUM('active','maintenance','retired') NOT NULL DEFAULT 'active', + `mileage` INT UNSIGNED DEFAULT NULL, + `last_inspection` DATE DEFAULT NULL, + `next_inspection` DATE DEFAULT NULL, + `insurance_expiry` DATE DEFAULT NULL, + `gps_device_id` VARCHAR(100) DEFAULT NULL, + `notes` TEXT, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_tech` (`assigned_tech_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_dispatches` ( +-- ============================================================ +-- Truck Stock — parts inventory per vehicle +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_truck_stock` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `vehicle_id` INT UNSIGNED NOT NULL, + `product_id` INT UNSIGNED NOT NULL COMMENT 'FK to CRM products', + `quantity` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `min_quantity` DECIMAL(10,2) NOT NULL DEFAULT 1.00 COMMENT 'Reorder point', + `last_restocked` DATE DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_vehicle_product` (`vehicle_id`, `product_id`), + KEY `idx_vehicle` (`vehicle_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Dispatch Log — assignment and routing history +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_dispatch_log` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `work_order_id` INT UNSIGNED NOT NULL, `technician_id` INT UNSIGNED NOT NULL, - `status` ENUM('offered','accepted','rejected','en_route','arrived','completed','cancelled') NOT NULL DEFAULT 'offered', - `offered_at` DATETIME NOT NULL, - `responded_at` DATETIME DEFAULT NULL, - `arrived_at` DATETIME DEFAULT NULL, - `distance_km` DECIMAL(10,2) DEFAULT NULL, - `eta_minutes` DECIMAL(10,2) DEFAULT NULL, - `attempt_number` TINYINT UNSIGNED NOT NULL DEFAULT 1, + `action` ENUM('assigned','accepted','rejected','en_route','arrived','completed','reassigned','cancelled') NOT NULL, + `notes` TEXT, + `latitude` DECIMAL(10,7) DEFAULT NULL, + `longitude` DECIMAL(10,7) DEFAULT NULL, + `created_by` INT NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_wo` (`work_order_id`), + KEY `idx_tech` (`technician_id`), + KEY `idx_action` (`action`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Estimates — on-site quoting +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_estimates` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `work_order_id` INT UNSIGNED DEFAULT NULL COMMENT 'Generated from a WO inspection', + `contact_id` INT NOT NULL, + `location_id` INT UNSIGNED DEFAULT NULL, + `estimate_number` VARCHAR(30) NOT NULL DEFAULT '', + `title` VARCHAR(255) NOT NULL, + `description` TEXT, + `parts_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `labor_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `tax` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `status` ENUM('draft','sent','viewed','accepted','declined','expired','converted') NOT NULL DEFAULT 'draft', + `valid_days` INT UNSIGNED NOT NULL DEFAULT 30, + `token` VARCHAR(64) DEFAULT NULL COMMENT 'Public view/accept token', + `accepted_at` DATETIME DEFAULT NULL, + `customer_signature` TEXT, + `converted_wo_id` INT UNSIGNED DEFAULT NULL COMMENT 'WO created from accepted estimate', + `created_by` INT NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_number` (`estimate_number`), + KEY `idx_contact` (`contact_id`), + KEY `idx_wo` (`work_order_id`), + KEY `idx_status` (`status`), + KEY `idx_token` (`token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- Time Entries — tech labor tracking per work order +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuitefield_time_entries` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `work_order_id` INT UNSIGNED NOT NULL, + `technician_id` INT UNSIGNED NOT NULL, + `start_time` DATETIME NOT NULL, + `end_time` DATETIME DEFAULT NULL, + `hours` DECIMAL(5,2) DEFAULT NULL, + `is_overtime` TINYINT NOT NULL DEFAULT 0, + `is_travel` TINYINT NOT NULL DEFAULT 0, + `rate` DECIMAL(10,2) DEFAULT NULL, `notes` TEXT, `created` DATETIME NOT NULL, PRIMARY KEY (`id`), - KEY `idx_work_order` (`work_order_id`), - KEY `idx_technician` (`technician_id`), - KEY `idx_status` (`status`) + KEY `idx_wo` (`work_order_id`), + KEY `idx_tech` (`technician_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql b/source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql index a7d8a93..e804a60 100644 --- a/source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql +++ b/source/packages/plg_system_mokosuitefield/sql/uninstall.mysql.sql @@ -1,10 +1,12 @@ -DROP TABLE IF EXISTS `#__mokosuitefield_dispatches`; -DROP TABLE IF EXISTS `#__mokosuitefield_pm_agreements`; -DROP TABLE IF EXISTS `#__mokosuitefield_checklists`; -DROP TABLE IF EXISTS `#__mokosuitefield_work_order_parts`; -DROP TABLE IF EXISTS `#__mokosuitefield_truck_inventory`; -DROP TABLE IF EXISTS `#__mokosuitefield_parts`; -DROP TABLE IF EXISTS `#__mokosuitefield_equipment_history`; -DROP TABLE IF EXISTS `#__mokosuitefield_technicians`; -DROP TABLE IF EXISTS `#__mokosuitefield_equipment`; +DROP TABLE IF EXISTS `#__mokosuitefield_time_entries`; +DROP TABLE IF EXISTS `#__mokosuitefield_dispatch_log`; +DROP TABLE IF EXISTS `#__mokosuitefield_estimates`; +DROP TABLE IF EXISTS `#__mokosuitefield_truck_stock`; +DROP TABLE IF EXISTS `#__mokosuitefield_vehicles`; +DROP TABLE IF EXISTS `#__mokosuitefield_wo_photos`; +DROP TABLE IF EXISTS `#__mokosuitefield_wo_items`; DROP TABLE IF EXISTS `#__mokosuitefield_work_orders`; +DROP TABLE IF EXISTS `#__mokosuitefield_agreements`; +DROP TABLE IF EXISTS `#__mokosuitefield_equipment`; +DROP TABLE IF EXISTS `#__mokosuitefield_locations`; +DROP TABLE IF EXISTS `#__mokosuitefield_technicians`; diff --git a/source/packages/plg_system_mokosuitefield/src/Extension/Field.php b/source/packages/plg_system_mokosuitefield/src/Extension/Field.php deleted file mode 100644 index d92d8d9..0000000 --- a/source/packages/plg_system_mokosuitefield/src/Extension/Field.php +++ /dev/null @@ -1,24 +0,0 @@ - - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - -namespace Moko\Plugin\System\MokoSuiteField\Extension; - -defined('_JEXEC') or die; - -use Joomla\CMS\Plugin\CMSPlugin; -use Joomla\Event\SubscriberInterface; - -class Field extends CMSPlugin implements SubscriberInterface -{ - protected $autoloadLanguage = true; - - public static function getSubscribedEvents(): array - { - return []; - } -} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/CustomerFeedbackHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/CustomerFeedbackHelper.php new file mode 100644 index 0000000..21df3a5 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/CustomerFeedbackHelper.php @@ -0,0 +1,132 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('wo.id, wo.wo_number, wo.contact_id, cd.name AS customer_name, cd.email_to, cd.telephone') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->where('wo.id = ' . (int) $woId)); + $wo = $db->loadObject(); + + if (!$wo || !$wo->email_to) { + return (object) ['success' => false, 'error' => 'No email for feedback request']; + } + + // Generate unique feedback token + $token = bin2hex(random_bytes(16)); + + $request = (object) [ + 'wo_id' => $woId, + 'contact_id' => $wo->contact_id, + 'token' => $token, + 'status' => 'sent', + 'sent_at' => Factory::getDate()->toSql(), + ]; + $db->insertObject('#__mokosuitefield_feedback_requests', $request, 'id'); + + return (object) ['success' => true, 'token' => $token, 'email' => $wo->email_to]; + } + + /** + * Submit feedback (called from public survey page via token). + */ + public static function submitFeedback(string $token, int $rating, int $npsScore, string $comments = ''): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Validate inputs before DB access + $rating = max(1, min(5, $rating)); + $npsScore = max(0, min(10, $npsScore)); + + $filter = \Joomla\Filter\InputFilter::getInstance(); + $comments = $filter->clean($comments, 'STRING'); + + $db->transactionStart(); + try { + // Lock the request row to prevent race condition on token reuse + $db->setQuery('SELECT id, wo_id, contact_id, status FROM #__mokosuitefield_feedback_requests WHERE ' + . $db->quoteName('token') . ' = ' . $db->quote($token) . ' FOR UPDATE'); + $request = $db->loadObject(); + + if (!$request) { + $db->transactionRollback(); + return (object) ['success' => false, 'error' => 'Invalid feedback link']; + } + if ($request->status === 'completed') { + $db->transactionRollback(); + return (object) ['success' => false, 'error' => 'Feedback already submitted']; + } + + $feedback = (object) [ + 'request_id' => $request->id, + 'wo_id' => $request->wo_id, + 'contact_id' => $request->contact_id, + 'rating' => $rating, + 'nps_score' => $npsScore, + 'comments' => $comments, + 'submitted_at' => Factory::getDate()->toSql(), + ]; + $db->insertObject('#__mokosuitefield_feedback', $feedback); + + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitefield_feedback_requests') + ->set($db->quoteName('status') . ' = ' . $db->quote('completed')) + ->where('id = ' . (int) $request->id)); + $db->execute(); + + $db->transactionCommit(); + } catch (\Throwable $e) { + $db->transactionRollback(); + // Don't leak internal errors to public users + return (object) ['success' => false, 'error' => 'Unable to save feedback. Please try again.']; + } + + return (object) ['success' => true, 'rating' => $rating, 'nps' => $npsScore]; + } + + /** + * Get NPS score and satisfaction summary. + */ + public static function getSatisfactionSummary(string $from = '', string $to = ''): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $from = $from ?: date('Y-01-01'); + $to = $to ?: date('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total_responses') + ->select('COALESCE(AVG(rating), 0) AS avg_rating') + ->select('COALESCE(AVG(nps_score), 0) AS avg_nps') + ->select('SUM(CASE WHEN nps_score >= 9 THEN 1 ELSE 0 END) AS promoters') + ->select('SUM(CASE WHEN nps_score BETWEEN 7 AND 8 THEN 1 ELSE 0 END) AS passives') + ->select('SUM(CASE WHEN nps_score <= 6 THEN 1 ELSE 0 END) AS detractors') + ->from('#__mokosuitefield_feedback') + ->where('DATE(submitted_at) BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to))); + + $stats = $db->loadObject() ?: (object) ['total_responses' => 0, 'avg_rating' => 0, 'avg_nps' => 0, 'promoters' => 0, 'passives' => 0, 'detractors' => 0]; + + $total = (int) $stats->total_responses; + $stats->nps_score = $total > 0 + ? round(((int) $stats->promoters - (int) $stats->detractors) / $total * 100) + : 0; + + return $stats; + } +} 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; + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php new file mode 100644 index 0000000..487b566 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/DispatchHelper.php @@ -0,0 +1,144 @@ +get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('t.*, cd.name AS tech_name, cd.telephone') + ->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.status IN (' . $db->quote('dispatched') . ',' . $db->quote('en_route') . ',' . $db->quote('on_site') . ',' . $db->quote('in_progress') . ') AND wo.scheduled_date = CURDATE()) AS today_jobs') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->where($db->quoteName('t.status') . ' = ' . $db->quote('available')) + ->where('(' . $db->quoteName('t.trade') . ' = ' . $db->quote($trade) . ' OR ' . $db->quoteName('t.trade') . ' = ' . $db->quote('multi_trade') . ')') + ->having('today_jobs < t.max_daily_jobs') + ->order('today_jobs ASC'); + + $db->setQuery($query); + $techs = $db->loadObjectList() ?: []; + + if (empty($techs)) return null; + + // If skills required, filter + if ($requiredSkills) { + $techs = array_filter($techs, function ($t) use ($requiredSkills) { + $techSkills = json_decode($t->skills ?? '[]', true) ?: []; + foreach ($requiredSkills as $skill) { + if (!in_array($skill, $techSkills)) return false; + } + return true; + }); + } + + return !empty($techs) ? reset($techs) : null; + } + + /** + * Dispatch a work order to a technician. + */ + public static function dispatch(int $workOrderId, int $technicianId): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $db->updateObject('#__mokosuitefield_work_orders', (object) [ + 'id' => $workOrderId, + 'technician_id' => $technicianId, + 'status' => 'dispatched', + 'modified' => $now, + ], 'id'); + + $db->updateObject('#__mokosuitefield_technicians', (object) [ + 'id' => $technicianId, + 'status' => 'dispatched', + ], 'id'); + + // Log dispatch + $db->insertObject('#__mokosuitefield_dispatch_log', (object) [ + 'work_order_id' => $workOrderId, + 'technician_id' => $technicianId, + 'action' => 'assigned', + 'created_by' => Factory::getApplication()->getIdentity()->id, + 'created' => $now, + ]); + + return true; + } + + /** + * Get today's dispatch board — all jobs organized by tech. + */ + public static function getDispatchBoard(string $date = ''): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $date = $date ?: date('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('t.id AS tech_id, cd.name AS tech_name, t.status AS tech_status, t.trade') + ->select('wo.id AS wo_id, wo.wo_number, wo.status AS wo_status, wo.priority, wo.category') + ->select('wo.scheduled_time_start, wo.scheduled_time_end') + ->select('loc.address, loc.city, cust.name AS customer_name') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_work_orders', 'wo') . ' ON wo.technician_id = t.id AND wo.scheduled_date = ' . $db->quote($date) . ' AND wo.status NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ')') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cust') . ' ON cust.id = wo.contact_id') + ->order('t.id ASC, wo.scheduled_time_start ASC')); + + $rows = $db->loadObjectList() ?: []; + + // Group by tech + $board = []; + foreach ($rows as $row) { + $techId = (int) $row->tech_id; + if (!isset($board[$techId])) { + $board[$techId] = (object) [ + 'tech_id' => $techId, + 'tech_name' => $row->tech_name, + 'status' => $row->tech_status, + 'trade' => $row->trade, + 'jobs' => [], + ]; + } + if ($row->wo_id) { + $board[$techId]->jobs[] = $row; + } + } + + return array_values($board); + } + + /** + * Get unassigned work orders. + */ + public static function getUnassigned(): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('wo.*, cd.name AS customer_name, loc.address, loc.city, loc.zip') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->where($db->quoteName('wo.technician_id') . ' IS NULL') + ->where($db->quoteName('wo.status') . ' = ' . $db->quote('new')) + ->order('FIELD(wo.priority, ' . $db->quote('emergency') . ',' . $db->quote('urgent') . ',' . $db->quote('high') . ',' . $db->quote('normal') . ',' . $db->quote('low') . ',' . $db->quote('scheduled') . ') ASC, wo.created ASC')); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/EquipmentHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/EquipmentHelper.php new file mode 100644 index 0000000..1d56d11 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/EquipmentHelper.php @@ -0,0 +1,77 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitefield_equipment') + ->where('location_id = ' . $locationId) + ->order('equipment_type ASC, make ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function getByQrCode(string $qrCode): ?object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true)->select('e.*, loc.address, loc.city, cd.name AS owner_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'e')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->where($db->quoteName('e.qr_code') . ' = ' . $db->quote($qrCode))); + + return $db->loadObject() ?: null; + } + + public static function getDueForService(int $daysAhead = 30): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('e.*, loc.address, loc.city, cd.name AS owner_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'e')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->where($db->quoteName('e.next_service_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)') + ->order('e.next_service_date ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function getWarrantyExpiring(int $daysAhead = 90): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('e.*, loc.address, cd.name AS owner_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'e')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = e.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->where($db->quoteName('e.warranty_expiry') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)') + ->order('e.warranty_expiry ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function recordService(int $equipmentId): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->updateObject('#__mokosuitefield_equipment', (object) [ + 'id' => $equipmentId, 'last_service_date' => date('Y-m-d'), 'modified' => Factory::getDate()->toSql(), + ], 'id'); + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/EstimateHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/EstimateHelper.php new file mode 100644 index 0000000..3ef628c --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/EstimateHelper.php @@ -0,0 +1,83 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + $seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuitefield_estimates'))->loadResult() + 1; + + $estimate = (object) [ + 'estimate_number' => 'EST-' . date('Ymd') . '-' . str_pad($seq, 4, '0', STR_PAD_LEFT), + 'contact_id' => $contactId, + 'location_id' => (int) ($data['location_id'] ?? 0) ?: null, + 'work_order_id' => (int) ($data['work_order_id'] ?? 0) ?: null, + 'title' => $title, + 'description' => $data['description'] ?? '', + 'total' => (float) ($data['total'] ?? 0), + 'status' => 'draft', + 'valid_days' => (int) ($data['valid_days'] ?? 30), + 'token' => bin2hex(random_bytes(32)), + 'created_by' => Factory::getApplication()->getIdentity()->id, + 'created' => $now, + ]; + + $db->insertObject('#__mokosuitefield_estimates', $estimate, 'id'); + return (int) $estimate->id; + } + + public static function send(int $estimateId): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true) + ->select('e.*, cd.name AS customer_name, cd.email_to') + ->from($db->quoteName('#__mokosuitefield_estimates', 'e')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->where('e.id = ' . $estimateId)); + $est = $db->loadObject(); + + if (!$est || !$est->email_to) return false; + + $url = Uri::root() . 'index.php?option=com_mokosuitefield&view=estimate&token=' . $est->token; + $mailer = Factory::getMailer(); + $mailer->addRecipient($est->email_to, $est->customer_name); + $mailer->setSubject('Estimate ' . $est->estimate_number); + $mailer->setBody("Estimate ready for review:\n{$url}\n\nTotal: \$" . number_format((float) $est->total, 2)); + $result = $mailer->Send(); + + if ($result) { + $db->updateObject('#__mokosuitefield_estimates', (object) ['id' => $estimateId, 'status' => 'sent'], 'id'); + } + return (bool) $result; + } + + public static function accept(string $token, ?string $signature = null): ?int + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuitefield_estimates') + ->where($db->quoteName('token') . ' = ' . $db->quote($token)) + ->where($db->quoteName('status') . ' IN (' . $db->quote('sent') . ',' . $db->quote('viewed') . ')')); + $est = $db->loadObject(); + if (!$est) return null; + + $db->updateObject('#__mokosuitefield_estimates', (object) [ + 'id' => $est->id, 'status' => 'accepted', 'accepted_at' => Factory::getDate()->toSql(), + 'customer_signature' => $signature, + ], 'id'); + + $woId = WorkOrderHelper::create((int) $est->contact_id, 'general', $est->title, [ + 'location_id' => $est->location_id, 'source' => 'website', + ]); + + $db->updateObject('#__mokosuitefield_estimates', (object) ['id' => $est->id, 'status' => 'converted', 'converted_wo_id' => $woId], 'id'); + return $woId; + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/GpsTrackingHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/GpsTrackingHelper.php new file mode 100644 index 0000000..99c8cf8 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/GpsTrackingHelper.php @@ -0,0 +1,112 @@ + 90 || $longitude < -180 || $longitude > 180) { + throw new \InvalidArgumentException('Invalid GPS coordinates.'); + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $ping = (object) [ + 'vehicle_id' => $vehicleId, + 'latitude' => round($latitude, 6), + 'longitude' => round($longitude, 6), + 'speed_mph' => max(0, round($speed, 1)), + 'recorded_at'=> Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitefield_gps_pings', $ping); + + return true; + } + + /** + * Get latest position for all active vehicles. + */ + public static function getFleetPositions(): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('v.id AS vehicle_id, v.name AS vehicle_name, v.license_plate') + ->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at') + ->select('cd.name AS assigned_tech') + ->from($db->quoteName('#__mokosuitefield_vehicles', 'v')) + ->join('LEFT', '(SELECT g1.* FROM #__mokosuitefield_gps_pings g1' + . ' INNER JOIN (SELECT vehicle_id, MAX(recorded_at) AS max_at' + . ' FROM #__mokosuitefield_gps_pings GROUP BY vehicle_id) g2' + . ' ON g1.vehicle_id = g2.vehicle_id AND g1.recorded_at = g2.max_at) AS gp' + . ' ON gp.vehicle_id = v.id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.vehicle_id = v.id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->where($db->quoteName('v.status') . ' = ' . $db->quote('active')) + ->order('v.name ASC')); + + return $db->loadObjectList() ?: []; + } + + /** + * Get drive history for a vehicle on a specific date. + */ + public static function getDriveHistory(int $vehicleId, string $date = ''): array + { + $date = $date ?: date('Y-m-d'); + + if (!\DateTime::createFromFormat('Y-m-d', $date)) { + throw new \InvalidArgumentException('Date must be Y-m-d format.'); + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at') + ->from($db->quoteName('#__mokosuitefield_gps_pings', 'gp')) + ->where('gp.vehicle_id = ' . (int) $vehicleId) + ->where('DATE(gp.recorded_at) = ' . $db->quote($date)) + ->order('gp.recorded_at ASC')); + + return $db->loadObjectList() ?: []; + } + + /** + * Get vehicles currently exceeding speed threshold. + */ + public static function getSpeeding(float $thresholdMph = 70): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('v.id AS vehicle_id, v.name AS vehicle_name, v.license_plate') + ->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at') + ->select('cd.name AS assigned_tech') + ->from($db->quoteName('#__mokosuitefield_vehicles', 'v')) + ->join('INNER', '(SELECT g1.* FROM #__mokosuitefield_gps_pings g1' + . ' INNER JOIN (SELECT vehicle_id, MAX(recorded_at) AS max_at' + . ' FROM #__mokosuitefield_gps_pings GROUP BY vehicle_id) g2' + . ' ON g1.vehicle_id = g2.vehicle_id AND g1.recorded_at = g2.max_at) AS gp' + . ' ON gp.vehicle_id = v.id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.vehicle_id = v.id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->where($db->quoteName('v.status') . ' = ' . $db->quote('active')) + ->where('gp.speed_mph > ' . (float) $thresholdMph) + ->where('gp.recorded_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE)') + ->order('gp.speed_mph DESC')); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/InvoiceHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/InvoiceHelper.php new file mode 100644 index 0000000..83e51b7 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/InvoiceHelper.php @@ -0,0 +1,151 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + $woId = (int) $woId; + + // Load work order + $db->setQuery($db->getQuery(true) + ->select('wo.*, cd.name AS customer_name') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->where('wo.id = ' . $woId)); + $wo = $db->loadObject(); + + if (!$wo) throw new \RuntimeException('Work order not found: ' . $woId); + + $db->transactionStart(); + + $params = Factory::getApplication()->getParams('com_mokosuitefield'); + $laborRate = (float) $params->get('default_labor_rate', 85.00); + + // Get time entries + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitefield_time_entries') + ->where('wo_id = ' . $woId)); + $timeEntries = $db->loadObjectList() ?: []; + + $totalHours = 0; + foreach ($timeEntries as $te) { + $totalHours += (float) $te->hours; + } + + // Get parts used + $db->setQuery($db->getQuery(true) + ->select('wi.*, p.title AS part_name, p.price') + ->from($db->quoteName('#__mokosuitefield_wo_items', 'wi')) + ->join('LEFT', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = wi.product_id') + ->where('wi.wo_id = ' . $woId)); + $parts = $db->loadObjectList() ?: []; + + $partsTotal = 0; + foreach ($parts as $part) { + $partsTotal += (float) ($part->price ?? 0) * (int) $part->quantity; + } + + $laborTotal = round($totalHours * $laborRate, 2); + $subtotal = $laborTotal + $partsTotal; + $taxRate = (float) $params->get('default_tax_rate', 0.07); + $tax = round($subtotal * $taxRate, 2); + $total = $subtotal + $tax; + + // Create CRM invoice + $seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuite_crm_invoices'))->loadResult() + 1; + + $invoice = (object) [ + 'contact_id' => $wo->contact_id, + 'invoice_number' => 'FSI-' . date('Ymd') . '-' . str_pad($seq, 4, '0', STR_PAD_LEFT), + 'type' => 'standard', + 'subtotal' => $subtotal, + 'tax' => $tax, + 'total' => $total, + 'balance_due' => $total, + 'status' => 'draft', + 'due_date' => date('Y-m-d', strtotime('+30 days')), + 'notes' => 'Field Service Invoice for WO #' . ($wo->wo_number ?? $woId), + 'created' => $now, + 'created_by' => Factory::getApplication()->getIdentity()->id, + ]; + $db->insertObject('#__mokosuite_crm_invoices', $invoice, 'id'); + $invoiceId = (int) $invoice->id; + + // Add labor line item + if ($totalHours > 0) { + $db->insertObject('#__mokosuite_crm_invoice_items', (object) [ + 'invoice_id' => $invoiceId, + 'description' => 'Labor — ' . number_format($totalHours, 1) . ' hours @ $' . number_format($laborRate, 2) . '/hr', + 'quantity' => $totalHours, + 'unit_price' => $laborRate, + 'line_total' => $laborTotal, + ]); + } + + // Add parts line items + foreach ($parts as $part) { + $db->insertObject('#__mokosuite_crm_invoice_items', (object) [ + 'invoice_id' => $invoiceId, + 'product_id' => $part->product_id, + 'description' => $part->part_name ?? 'Part', + 'quantity' => $part->quantity, + 'unit_price' => $part->price ?? 0, + 'line_total' => round((float) ($part->price ?? 0) * (int) $part->quantity, 2), + ]); + } + + // Link invoice to work order + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitefield_work_orders') + ->set('invoice_id = ' . $invoiceId) + ->where('id = ' . $woId)); + $db->execute(); + + $db->transactionCommit(); + + return $invoiceId; + } + + /** + * Batch generate invoices for all completed, uninvoiced work orders. + */ + public static function batchGenerate(): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('id') + ->from('#__mokosuitefield_work_orders') + ->where($db->quoteName('status') . ' = ' . $db->quote('completed')) + ->where('invoice_id IS NULL OR invoice_id = 0') + ->order('completed_at ASC')); + $woIds = $db->loadColumn() ?: []; + + $results = []; + foreach ($woIds as $woId) { + try { + $invoiceId = self::generateFromWorkOrder((int) $woId); + $results[] = ['wo_id' => $woId, 'invoice_id' => $invoiceId, 'success' => true]; + } catch (\Throwable $e) { + $results[] = ['wo_id' => $woId, 'error' => $e->getMessage(), 'success' => false]; + } + } + + return $results; + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/PartsHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/PartsHelper.php new file mode 100644 index 0000000..38532a0 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/PartsHelper.php @@ -0,0 +1,107 @@ +get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('p.id, p.title AS name, p.sku, p.price, p.cost_price, p.stock_quantity') + ->select('COUNT(wi.id) AS usage_count') + ->from($db->quoteName('#__mokosuite_crm_products', 'p')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_wo_items', 'wi') . ' ON wi.product_id = p.id') + ->where($db->quoteName('p.published') . ' = 1') + ->group('p.id') + ->order('usage_count DESC'); + + if ($trade) { + $query->join('LEFT', $db->quoteName('#__mokosuitefield_work_orders', 'wo') . ' ON wo.id = wi.wo_id') + ->where($db->quoteName('wo.trade') . ' = ' . $db->quote($trade)); + } + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } + + /** + * Record part usage on a work order. + */ + public static function usePart(int $woId, int $productId, int $quantity, ?float $unitPrice = null): int + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + if ($unitPrice === null) { + $db->setQuery($db->getQuery(true)->select('price')->from('#__mokosuite_crm_products')->where('id = ' . (int) $productId)); + $unitPrice = (float) $db->loadResult(); + } + + $item = (object) [ + 'wo_id' => $woId, + 'product_id' => $productId, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'line_total' => round($unitPrice * $quantity, 2), + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitefield_wo_items', $item, 'id'); + + // Deduct from stock + $db->setQuery($db->getQuery(true) + ->update('#__mokosuite_crm_products') + ->set('stock_quantity = stock_quantity - ' . (int) $quantity) + ->where('id = ' . (int) $productId)); + $db->execute(); + + return (int) $item->id; + } + + /** + * Get parts that are low on stock and frequently used in field service. + */ + public static function getLowStockParts(int $limit = 20): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('p.id, p.title AS name, p.sku, p.stock_quantity, p.reorder_point, p.cost_price') + ->select('(SELECT COUNT(*) FROM #__mokosuitefield_wo_items wi WHERE wi.product_id = p.id) AS field_usage') + ->from($db->quoteName('#__mokosuite_crm_products', 'p')) + ->where($db->quoteName('p.stock_quantity') . ' <= ' . $db->quoteName('p.reorder_point')) + ->where($db->quoteName('p.reorder_point') . ' > 0') + ->where('p.id IN (SELECT DISTINCT product_id FROM #__mokosuitefield_wo_items)') + ->order('(p.reorder_point - p.stock_quantity) DESC'), 0, $limit); + + return $db->loadObjectList() ?: []; + } + + /** + * Get parts cost summary for a work order. + */ + public static function getWoPartsCost(int $woId): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS item_count') + ->select('SUM(quantity) AS total_qty') + ->select('COALESCE(SUM(line_total), 0) AS total_cost') + ->from('#__mokosuitefield_wo_items') + ->where('wo_id = ' . (int) $woId)); + + return $db->loadObject() ?: (object) ['item_count' => 0, 'total_qty' => 0, 'total_cost' => 0]; + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php new file mode 100644 index 0000000..701348c --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php @@ -0,0 +1,239 @@ +get(DatabaseInterface::class); + $date = $date ?: date('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('wo.id, wo.wo_number, wo.priority, wo.status, wo.trade') + ->select('wo.scheduled_date, wo.scheduled_time, wo.estimated_duration') + ->select('wo.route_order') + ->select('l.name AS location_name, l.address, l.city, l.state, l.zip') + ->select('l.latitude, l.longitude') + ->select('cd.name AS customer_name, cd.telephone AS customer_phone') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'l') . ' ON l.id = wo.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->where($db->quoteName('wo.technician_id') . ' = ' . (int) $techId) + ->where($db->quoteName('wo.scheduled_date') . ' = ' . $db->quote($date)) + ->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('cancelled') . ',' . $db->quote('completed') . ')') + ->order('wo.route_order ASC, wo.scheduled_time ASC')); + + return $db->loadObjectList() ?: []; + } + + /** + * Auto-assign route order based on geographic proximity (nearest-neighbor heuristic). + * Starts from the tech's home base or first WO location. + */ + public static function optimizeRoute(int $techId, string $date = ''): array + { + $stops = self::getTechRoute($techId, $date); + if (count($stops) <= 1) return $stops; + + // Get tech home location + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true) + ->select('home_latitude, home_longitude') + ->from('#__mokosuitefield_technicians') + ->where('id = ' . (int) $techId)); + $tech = $db->loadObject(); + + $currentLat = (float) ($tech->home_latitude ?? 0); + $currentLng = (float) ($tech->home_longitude ?? 0); + + // Nearest-neighbor sort + $ordered = []; + $remaining = $stops; + + while (!empty($remaining)) { + $bestIdx = 0; + $bestDist = PHP_FLOAT_MAX; + + foreach ($remaining as $idx => $stop) { + $lat = (float) ($stop->latitude ?? 0); + $lng = (float) ($stop->longitude ?? 0); + + if ($lat === 0.0 && $lng === 0.0) { + // No coordinates — keep in original position + $dist = PHP_FLOAT_MAX - 1; + } else { + $dist = self::haversine($currentLat, $currentLng, $lat, $lng); + } + + if ($dist < $bestDist) { + $bestDist = $dist; + $bestIdx = $idx; + } + } + + $next = $remaining[$bestIdx]; + $ordered[] = $next; + $currentLat = (float) ($next->latitude ?? $currentLat); + $currentLng = (float) ($next->longitude ?? $currentLng); + array_splice($remaining, $bestIdx, 1); + $remaining = array_values($remaining); + } + + // Save route order + foreach ($ordered as $i => $stop) { + $update = (object) [ + 'id' => $stop->id, + 'route_order' => $i + 1, + ]; + $db->updateObject('#__mokosuitefield_work_orders', $update, 'id'); + $ordered[$i]->route_order = $i + 1; + } + + return $ordered; + } + + /** + * Manually reorder a stop within a tech's route. + */ + public static function reorderStop(int $woId, int $newPosition): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('technician_id, scheduled_date') + ->from('#__mokosuitefield_work_orders') + ->where('id = ' . (int) $woId)); + $wo = $db->loadObject(); + if (!$wo) return false; + + $stops = self::getTechRoute((int) $wo->technician_id, $wo->scheduled_date); + + // Find and remove the target WO + $target = null; + $filtered = []; + foreach ($stops as $stop) { + if ((int) $stop->id === $woId) { + $target = $stop; + } else { + $filtered[] = $stop; + } + } + + if (!$target) return false; + + // Insert at new position + $newPosition = max(1, min($newPosition, count($filtered) + 1)); + array_splice($filtered, $newPosition - 1, 0, [$target]); + + // Save new order + foreach ($filtered as $i => $stop) { + $update = (object) [ + 'id' => $stop->id, + 'route_order' => $i + 1, + ]; + $db->updateObject('#__mokosuitefield_work_orders', $update, 'id'); + } + + return true; + } + + /** + * Estimate total drive time and distance for a route (using straight-line approximation). + */ + public static function estimateRouteMetrics(int $techId, string $date = ''): object + { + $stops = self::getTechRoute($techId, $date); + + $totalDistance = 0.0; + $totalJobTime = 0; + $legs = []; + + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true) + ->select('home_latitude, home_longitude') + ->from('#__mokosuitefield_technicians') + ->where('id = ' . (int) $techId)); + $tech = $db->loadObject(); + + $prevLat = (float) ($tech->home_latitude ?? 0); + $prevLng = (float) ($tech->home_longitude ?? 0); + + foreach ($stops as $stop) { + $lat = (float) ($stop->latitude ?? 0); + $lng = (float) ($stop->longitude ?? 0); + $dist = 0; + + if ($lat && $lng && ($prevLat || $prevLng)) { + $dist = self::haversine($prevLat, $prevLng, $lat, $lng); + } + + $totalDistance += $dist; + $totalJobTime += (int) ($stop->estimated_duration ?? 60); + + $legs[] = (object) [ + 'wo_id' => $stop->id, + 'location' => $stop->location_name ?? $stop->address, + 'distance' => round($dist, 1), + 'drive_min'=> round($dist / self::AVG_SPEED_MPH * 60, 0), + ]; + + $prevLat = $lat ?: $prevLat; + $prevLng = $lng ?: $prevLng; + } + + $totalDriveMin = $totalDistance > 0 ? round($totalDistance / self::AVG_SPEED_MPH * 60) : 0; + + return (object) [ + 'stop_count' => count($stops), + 'total_distance' => round($totalDistance, 1), + 'total_drive_min'=> $totalDriveMin, + 'total_job_min' => $totalJobTime, + 'total_day_min' => $totalDriveMin + $totalJobTime, + 'legs' => $legs, + ]; + } + + /** + * Haversine distance in miles. + */ + private static function haversine(float $lat1, float $lon1, float $lat2, float $lon2): float + { + $R = 3959; // Earth radius in miles + $dLat = deg2rad($lat2 - $lat1); + $dLon = deg2rad($lon2 - $lon1); + $a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon / 2) ** 2; + $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); + return $R * $c; + } + + /** + * Log a GPS breadcrumb for a tech (called from mobile app). + */ + public static function logGpsBreadcrumb(int $techId, float $lat, float $lng, ?int $woId = null): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $crumb = (object) [ + 'technician_id' => $techId, + 'latitude' => $lat, + 'longitude' => $lng, + 'wo_id' => $woId, + 'recorded_at' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitefield_dispatch_log', $crumb); + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/SafetyChecklistHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/SafetyChecklistHelper.php new file mode 100644 index 0000000..c789fde --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/SafetyChecklistHelper.php @@ -0,0 +1,147 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $items = self::getDefaultItems($trade); + + $checklist = (object) [ + 'wo_id' => $woId, + 'trade' => $trade, + 'status' => 'pending', + 'created' => $now, + 'created_by' => Factory::getApplication()->getIdentity()->id, + ]; + $db->insertObject('#__mokosuitefield_safety_checklists', $checklist, 'id'); + $checklistId = (int) $checklist->id; + + foreach ($items as $i => $item) { + $db->insertObject('#__mokosuitefield_safety_checklist_items', (object) [ + 'checklist_id' => $checklistId, + 'item_text' => $item, + 'checked' => 0, + 'ordering' => $i + 1, + ]); + } + + return $checklistId; + } + + /** + * Complete a checklist item. + */ + public static function checkItem(int $itemId, bool $passed, string $notes = ''): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Verify item belongs to a pending checklist and is not already checked + $db->setQuery($db->getQuery(true) + ->select('sci.id, sci.checked, sc.status AS checklist_status') + ->from($db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci')) + ->join('INNER', $db->quoteName('#__mokosuitefield_safety_checklists', 'sc') . ' ON sc.id = sci.checklist_id') + ->where('sci.id = ' . (int) $itemId)); + $existing = $db->loadObject(); + + if (!$existing || $existing->checklist_status !== 'pending' || (int) $existing->checked === 1) { + return false; + } + + $update = (object) [ + 'id' => $itemId, + 'checked' => 1, + 'passed' => $passed ? 1 : 0, + 'notes' => $notes, + 'checked_at' => Factory::getDate()->toSql(), + 'checked_by' => Factory::getApplication()->getIdentity()->id, + ]; + + $db->updateObject('#__mokosuitefield_safety_checklist_items', $update, 'id'); + + // Auto-complete checklist if all items are checked + $db->setQuery($db->getQuery(true) + ->select('sc.id, COUNT(sci2.id) AS total, SUM(CASE WHEN sci2.checked = 1 THEN 1 ELSE 0 END) AS done') + ->from($db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci2')) + ->join('INNER', $db->quoteName('#__mokosuitefield_safety_checklists', 'sc') . ' ON sc.id = sci2.checklist_id') + ->where('sci2.id = ' . (int) $itemId) + ->group('sc.id')); + $progress = $db->loadObject(); + + if ($progress && (int) $progress->done === (int) $progress->total) { + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitefield_safety_checklists') + ->set($db->quoteName('status') . ' = ' . $db->quote('completed')) + ->where('id = ' . (int) $progress->id)); + $db->execute(); + } + + return true; + } + + /** + * Get checklist completion status for a work order. + */ + public static function getStatus(int $woId): ?object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('sc.id, sc.status') + ->select('COUNT(sci.id) AS total_items') + ->select('SUM(CASE WHEN sci.checked = 1 THEN 1 ELSE 0 END) AS checked_items') + ->select('SUM(CASE WHEN sci.checked = 1 AND sci.passed = 0 THEN 1 ELSE 0 END) AS failed_items') + ->from($db->quoteName('#__mokosuitefield_safety_checklists', 'sc')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci') . ' ON sci.checklist_id = sc.id') + ->where('sc.wo_id = ' . (int) $woId) + ->group('sc.id') + ->order('sc.created DESC')); + + $status = $db->loadObject(); + if (!$status) return null; + + $status->complete = (int) $status->checked_items === (int) $status->total_items; + $status->all_passed = (int) $status->failed_items === 0; + $status->safe_to_proceed = $status->complete && $status->all_passed; + + return $status; + } + + /** + * Get default safety items by trade. + */ + private static function getDefaultItems(string $trade): array + { + $common = [ + 'PPE worn (gloves, safety glasses, boots)', + 'Work area inspected for hazards', + 'Tools and equipment in good condition', + 'Fire extinguisher accessible', + 'Emergency exits identified', + ]; + + $tradeSpecific = match ($trade) { + 'electrical' => ['Lockout/tagout verified', 'Voltage tester functional', 'Grounding confirmed', 'Arc flash boundaries marked'], + 'plumbing' => ['Water supply shut off', 'Gas lines identified and marked', 'Asbestos check for older buildings', 'Drain protection in place'], + 'hvac' => ['Refrigerant handling certification verified', 'Electrical isolation confirmed', 'Ductwork supports inspected', 'Ladder/scaffold secured'], + 'general' => ['Work permit obtained if required', 'Material Safety Data Sheets reviewed'], + default => [], + }; + + return array_merge($common, $tradeSpecific); + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/SchedulingHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/SchedulingHelper.php new file mode 100644 index 0000000..c62c6d4 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/SchedulingHelper.php @@ -0,0 +1,152 @@ +get(DatabaseInterface::class); + $params = Factory::getApplication()->getParams('com_mokosuitefield'); + + $startHour = (int) $params->get('schedule_start_hour', 8); + $endHour = (int) $params->get('schedule_end_hour', 17); + $slotInterval = (int) $params->get('slot_interval_minutes', 30); + + // Get available techs for this trade + $db->setQuery($db->getQuery(true) + ->select('t.id, cd.name AS tech_name, t.max_daily_jobs') + ->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.scheduled_date = ' . $db->quote($date) . ' AND wo.status NOT IN (' . $db->quote('cancelled') . ',' . $db->quote('completed') . ')) AS booked_count') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->where($db->quoteName('t.status') . ' = ' . $db->quote('available')) + ->where('(' . $db->quoteName('t.trade') . ' = ' . $db->quote($trade) . ' OR ' . $db->quoteName('t.trade') . ' = ' . $db->quote('multi_trade') . ')') + ->having('booked_count < t.max_daily_jobs')); + $techs = $db->loadObjectList() ?: []; + + if (empty($techs)) return []; + + // Get existing WO times for these techs + $techIds = array_column($techs, 'id'); + $db->setQuery($db->getQuery(true) + ->select('technician_id, scheduled_time, estimated_duration') + ->from('#__mokosuitefield_work_orders') + ->where('scheduled_date = ' . $db->quote($date)) + ->where('technician_id IN (' . implode(',', array_map('intval', $techIds)) . ')') + ->where($db->quoteName('status') . ' NOT IN (' . $db->quote('cancelled') . ',' . $db->quote('completed') . ')')); + $existingWOs = $db->loadObjectList() ?: []; + + // Build blocked time ranges per tech + $blocked = []; + foreach ($existingWOs as $wo) { + $start = strtotime($wo->scheduled_time); + $end = $start + ((int) ($wo->estimated_duration ?? 60)) * 60; + $blocked[$wo->technician_id][] = [$start, $end]; + } + + // Generate slots + $slots = []; + $current = strtotime($date . ' ' . str_pad($startHour, 2, '0', STR_PAD_LEFT) . ':00:00'); + $dayEnd = strtotime($date . ' ' . str_pad($endHour, 2, '0', STR_PAD_LEFT) . ':00:00') - ($durationMin * 60); + + while ($current <= $dayEnd) { + $slotEnd = $current + ($durationMin * 60); + + // Find at least one available tech for this slot + $availableTechs = []; + foreach ($techs as $tech) { + $conflict = false; + foreach ($blocked[$tech->id] ?? [] as [$bStart, $bEnd]) { + if ($current < $bEnd && $slotEnd > $bStart) { + $conflict = true; + break; + } + } + if (!$conflict) { + $availableTechs[] = $tech->tech_name; + } + } + + if (!empty($availableTechs)) { + $slots[] = (object) [ + 'time' => date('H:i', $current), + 'display' => date('g:i A', $current), + 'available_techs' => count($availableTechs), + ]; + } + + $current += $slotInterval * 60; + } + + return $slots; + } + + /** + * Schedule a work order into a specific slot. + */ + public static function scheduleWorkOrder(int $woId, string $date, string $time, ?int $techId = null): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Auto-assign tech if not specified + if (!$techId) { + $wo = $db->setQuery($db->getQuery(true)->select('trade')->from('#__mokosuitefield_work_orders')->where('id = ' . (int) $woId))->loadObject(); + $bestTech = DispatchHelper::findBestTech($wo->trade ?? 'general', ''); + $techId = $bestTech ? (int) $bestTech->id : null; + } + + $update = (object) [ + 'id' => $woId, + 'scheduled_date' => $date, + 'scheduled_time' => $time, + 'technician_id' => $techId, + 'status' => 'scheduled', + ]; + + return $db->updateObject('#__mokosuitefield_work_orders', $update, 'id'); + } + + /** + * Get today's schedule for all techs. + */ + public static function getTodaySchedule(): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('t.id AS tech_id, cd.name AS tech_name, t.trade') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->where($db->quoteName('t.status') . ' != ' . $db->quote('inactive')) + ->order('cd.name ASC')); + $techs = $db->loadObjectList() ?: []; + + foreach ($techs as &$tech) { + $db->setQuery($db->getQuery(true) + ->select('wo.id, wo.wo_number, wo.scheduled_time, wo.estimated_duration, wo.status, wo.priority') + ->select('loc.address, loc.city') + ->select('cd2.name AS customer_name') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd2') . ' ON cd2.id = wo.contact_id') + ->where('wo.technician_id = ' . (int) $tech->tech_id) + ->where('wo.scheduled_date = CURDATE()') + ->where($db->quoteName('wo.status') . ' != ' . $db->quote('cancelled')) + ->order('wo.scheduled_time ASC')); + $tech->jobs = $db->loadObjectList() ?: []; + $tech->job_count = count($tech->jobs); + } + + return $techs; + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/ServiceAgreementHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/ServiceAgreementHelper.php new file mode 100644 index 0000000..ab2f919 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/ServiceAgreementHelper.php @@ -0,0 +1,78 @@ +get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('a.*, cd.name AS customer_name, loc.address') + ->from($db->quoteName('#__mokosuitefield_agreements', 'a')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = a.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = a.location_id') + ->where($db->quoteName('a.status') . ' = ' . $db->quote('active')) + ->order('a.end_date ASC'); + + if ($contactId) $query->where('a.contact_id = ' . $contactId); + + $db->setQuery($query); + $agreements = $db->loadObjectList() ?: []; + + foreach ($agreements as &$a) { + $a->visits_remaining = max(0, (int) $a->visits_per_year - (int) $a->visits_used); + $a->days_until_expiry = $a->end_date ? max(0, round((strtotime($a->end_date) - time()) / 86400)) : null; + } + + return $agreements; + } + + public static function getExpiring(int $daysAhead = 30): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('a.*, cd.name AS customer_name, cd.email_to') + ->from($db->quoteName('#__mokosuitefield_agreements', 'a')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = a.contact_id') + ->where($db->quoteName('a.status') . ' = ' . $db->quote('active')) + ->where($db->quoteName('a.end_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)') + ->order('a.end_date ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function getRevenueSummary(): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS active_agreements') + ->select('COALESCE(SUM(annual_amount), 0) AS annual_recurring') + ->select('COALESCE(SUM(annual_amount / 12), 0) AS monthly_recurring') + ->from('#__mokosuitefield_agreements') + ->where($db->quoteName('status') . ' = ' . $db->quote('active'))); + + return $db->loadObject() ?: (object) ['active_agreements' => 0, 'annual_recurring' => 0, 'monthly_recurring' => 0]; + } + + public static function recordVisit(int $agreementId, int $workOrderId): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitefield_agreements') + ->set('visits_used = visits_used + 1') + ->where('id = ' . $agreementId)); + $db->execute(); + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/TechnicianSkillHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/TechnicianSkillHelper.php new file mode 100644 index 0000000..bbc95d1 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/TechnicianSkillHelper.php @@ -0,0 +1,97 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('t.id AS tech_id, cd.name AS tech_name') + ->select('GROUP_CONCAT(ts.skill_name ORDER BY ts.skill_name SEPARATOR ", ") AS skills') + ->select('COUNT(ts.id) AS skill_count') + ->select('SUM(CASE WHEN ts.certification_expires IS NOT NULL AND ts.certification_expires < NOW() THEN 1 ELSE 0 END) AS expired_certs') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->join('LEFT', $db->quoteName('#__mokosuitefield_tech_skills', 'ts') . ' ON ts.tech_id = t.id') + ->where($db->quoteName('t.status') . ' = ' . $db->quote('active')) + ->group('t.id') + ->order('cd.name ASC')); + + return $db->loadObjectList() ?: []; + } + + /** + * Find best technician match for a work order based on required skills. + */ + public static function findBestMatch(array $requiredSkills, string $date = ''): array + { + if (empty($requiredSkills)) { + return []; + } + + $date = $date ?: date('Y-m-d'); + if (!\DateTime::createFromFormat('Y-m-d', $date)) { + throw new \InvalidArgumentException('Date must be Y-m-d format.'); + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $skillPlaceholders = implode(',', array_map(fn($s) => $db->quote($s), $requiredSkills)); + + $db->setQuery($db->getQuery(true) + ->select('t.id AS tech_id, cd.name AS tech_name, t.hourly_rate') + ->select('COUNT(DISTINCT ts.skill_name) AS matching_skills') + ->select((string) count($requiredSkills) . ' AS required_skills') + ->from($db->quoteName('#__mokosuitefield_technicians', 't')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->join('INNER', $db->quoteName('#__mokosuitefield_tech_skills', 'ts') + . ' ON ts.tech_id = t.id AND ts.skill_name IN (' . $skillPlaceholders . ')' + . ' AND (ts.certification_expires IS NULL OR ts.certification_expires > ' . $db->quote($date) . ')') + ->where($db->quoteName('t.status') . ' = ' . $db->quote('active')) + ->group('t.id') + ->order('matching_skills DESC, t.hourly_rate ASC')); + + $matches = $db->loadObjectList() ?: []; + + foreach ($matches as &$m) { + $m->match_pct = round((int) $m->matching_skills / (int) $m->required_skills * 100, 1); + } + + return $matches; + } + + /** + * Get expiring certifications within N days. + */ + public static function getExpiringCertifications(int $days = 30): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $cutoff = date('Y-m-d', strtotime("+{$days} days")); + + $db->setQuery($db->getQuery(true) + ->select('ts.id, ts.skill_name, ts.certification_number, ts.certification_expires') + ->select('cd.name AS tech_name, cd.email_to') + ->from($db->quoteName('#__mokosuitefield_tech_skills', 'ts')) + ->join('INNER', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = ts.tech_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->where($db->quoteName('t.status') . ' = ' . $db->quote('active')) + ->where('ts.certification_expires IS NOT NULL') + ->where('ts.certification_expires BETWEEN NOW() AND ' . $db->quote($cutoff)) + ->order('ts.certification_expires ASC')); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/TruckStockHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/TruckStockHelper.php new file mode 100644 index 0000000..fbd7aa6 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/TruckStockHelper.php @@ -0,0 +1,64 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('ts.*, p.name AS part_name, p.sku, p.cost_price') + ->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts')) + ->join('INNER', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id') + ->where('ts.vehicle_id = ' . $vehicleId) + ->order('p.name ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function getLowStock(): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('ts.*, p.name AS part_name, p.sku, v.vehicle_number') + ->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts')) + ->join('INNER', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id') + ->join('INNER', $db->quoteName('#__mokosuitefield_vehicles', 'v') . ' ON v.id = ts.vehicle_id') + ->where('ts.quantity <= ts.min_quantity') + ->order('ts.quantity ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function usePart(int $vehicleId, int $productId, float $qty = 1): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitefield_truck_stock') + ->set('quantity = quantity - ' . (float) $qty) + ->where('vehicle_id = ' . $vehicleId) + ->where('product_id = ' . $productId)); + $db->execute(); + + return $db->getAffectedRows() > 0; + } + + public static function restock(int $vehicleId, int $productId, float $qty): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery("INSERT INTO #__mokosuitefield_truck_stock (vehicle_id, product_id, quantity, last_restocked) VALUES ({$vehicleId}, {$productId}, {$qty}, CURDATE()) ON DUPLICATE KEY UPDATE quantity = quantity + {$qty}, last_restocked = CURDATE()"); + $db->execute(); + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/VehicleHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/VehicleHelper.php new file mode 100644 index 0000000..0d32603 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/VehicleHelper.php @@ -0,0 +1,44 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('v.*, cd.name AS assigned_tech_name') + ->select('(SELECT COUNT(*) FROM #__mokosuitefield_truck_stock ts WHERE ts.vehicle_id = v.id AND ts.quantity <= ts.min_quantity) AS low_stock_items') + ->from($db->quoteName('#__mokosuitefield_vehicles', 'v')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.assigned_tech_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->order('v.vehicle_number ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function getInspectionsDue(int $daysAhead = 30): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('v.*, cd.name AS tech_name') + ->from($db->quoteName('#__mokosuitefield_vehicles', 'v')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = v.assigned_tech_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id') + ->where($db->quoteName('v.status') . ' = ' . $db->quote('active')) + ->where($db->quoteName('v.next_inspection') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)') + ->order('v.next_inspection ASC')); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/WarrantyHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/WarrantyHelper.php new file mode 100644 index 0000000..093fef5 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/WarrantyHelper.php @@ -0,0 +1,102 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('e.id, e.serial_number, e.model, e.install_date') + ->select('e.warranty_start, e.warranty_end, e.warranty_provider, e.warranty_type') + ->from($db->quoteName('#__mokosuitefield_equipment', 'e')) + ->where('e.id = ' . (int) $equipmentId)); + $equipment = $db->loadObject(); + + if (!$equipment) return (object) ['found' => false]; + + $now = new \DateTime('today'); + $warrantyEnd = $equipment->warranty_end ? new \DateTime($equipment->warranty_end) : null; + + $equipment->under_warranty = $warrantyEnd && $now <= $warrantyEnd; + $equipment->days_remaining = $warrantyEnd ? max(0, (int) $now->diff($warrantyEnd)->format('%r%a')) : null; + $equipment->warranty_expired = $warrantyEnd && $now > $warrantyEnd; + + // Get claim history + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total_claims, COALESCE(SUM(claim_amount), 0) AS total_claimed') + ->from('#__mokosuitefield_warranty_claims') + ->where('equipment_id = ' . (int) $equipmentId)); + $claims = $db->loadObject(); + + $equipment->total_claims = (int) ($claims->total_claims ?? 0); + $equipment->total_claimed = (float) ($claims->total_claimed ?? 0); + + return $equipment; + } + + /** + * Submit a warranty claim. + */ + public static function submitClaim(int $equipmentId, int $woId, string $description, float $claimAmount): object + { + $warranty = self::checkWarranty($equipmentId); + + if (empty($warranty->id)) return (object) ['success' => false, 'error' => 'Equipment not found']; + if (!$warranty->under_warranty) return (object) ['success' => false, 'error' => 'Warranty expired']; + + // Verify work order exists and is linked to this equipment + $db->setQuery($db->getQuery(true)->select('id')->from('#__mokosuitefield_work_orders') + ->where('id = ' . (int) $woId)); + if (!(int) $db->loadResult()) { + return (object) ['success' => false, 'error' => 'Invalid work order']; + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $claim = (object) [ + 'equipment_id' => $equipmentId, + 'wo_id' => $woId, + 'description' => $description, + 'claim_amount' => $claimAmount, + 'status' => 'submitted', + 'submitted_at' => $now, + 'submitted_by' => Factory::getApplication()->getIdentity()->id, + ]; + + $db->insertObject('#__mokosuitefield_warranty_claims', $claim, 'id'); + return (object) ['success' => true, 'claim_id' => (int) $claim->id]; + } + + /** + * Get equipment with warranties expiring within N days. + */ + public static function getExpiringSoon(int $days = 90): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('e.*, l.name AS location_name, cd.name AS customer_name') + ->from($db->quoteName('#__mokosuitefield_equipment', 'e')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'l') . ' ON l.id = e.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = e.contact_id') + ->where($db->quoteName('e.warranty_end') . ' IS NOT NULL') + ->where($db->quoteName('e.warranty_end') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . (int) $days . ' DAY)') + ->order('e.warranty_end ASC')); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php new file mode 100644 index 0000000..e6270e2 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/WorkOrderHelper.php @@ -0,0 +1,162 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + // Read prefix from component config (config.xml) + $params = Factory::getApplication()->getParams('com_mokosuitefield'); + $woPrefix = $params->get('wo_prefix', 'WO'); + $defaultTrade = $params->get('default_trade', 'general'); + + if (!$trade) $trade = $defaultTrade; + + $seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuitefield_work_orders'))->loadResult() + 1; + + $wo = (object) [ + 'wo_number' => $woPrefix . '-' . date('Ymd') . '-' . str_pad($seq, 4, '0', STR_PAD_LEFT), + 'contact_id' => $contactId, + 'location_id' => (int) ($data['location_id'] ?? 0) ?: null, + 'trade' => $trade, + 'priority' => $data['priority'] ?? 'normal', + 'status' => 'new', + 'category' => $data['category'] ?? null, + 'description' => $description, + 'customer_po' => $data['customer_po'] ?? null, + 'scheduled_date' => $data['scheduled_date'] ?? null, + 'scheduled_time_start' => $data['time_start'] ?? null, + 'scheduled_time_end' => $data['time_end'] ?? null, + 'service_agreement_id' => (int) ($data['agreement_id'] ?? 0) ?: null, + 'source' => $data['source'] ?? 'phone', + 'created_by' => Factory::getApplication()->getIdentity()->id, + 'created' => $now, + ]; + + $db->insertObject('#__mokosuitefield_work_orders', $wo, 'id'); + + return (int) $wo->id; + } + + public static function updateStatus(int $woId, string $status, ?float $lat = null, ?float $lng = null): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $update = (object) ['id' => $woId, 'status' => $status, 'modified' => $now]; + + if ($status === 'on_site') $update->actual_arrival = $now; + if ($status === 'completed') $update->actual_departure = $now; + + $db->updateObject('#__mokosuitefield_work_orders', $update, 'id'); + + // Get tech ID for dispatch log + $db->setQuery($db->getQuery(true)->select('technician_id')->from('#__mokosuitefield_work_orders')->where('id = ' . $woId)); + $techId = (int) $db->loadResult(); + + if ($techId) { + $action = match ($status) { + 'en_route' => 'en_route', + 'on_site' => 'arrived', + 'completed' => 'completed', + 'cancelled' => 'cancelled', + default => null, + }; + + if ($action) { + $db->insertObject('#__mokosuitefield_dispatch_log', (object) [ + 'work_order_id' => $woId, + 'technician_id' => $techId, + 'action' => $action, + 'latitude' => $lat, + 'longitude' => $lng, + 'created_by' => Factory::getApplication()->getIdentity()->id, + 'created' => $now, + ]); + } + + // Update tech status + if ($status === 'completed' || $status === 'cancelled') { + $db->updateObject('#__mokosuitefield_technicians', (object) ['id' => $techId, 'status' => 'available'], 'id'); + } elseif ($status === 'en_route') { + $db->updateObject('#__mokosuitefield_technicians', (object) [ + 'id' => $techId, 'status' => 'en_route', 'current_lat' => $lat, 'current_lng' => $lng, 'last_location_update' => $now, + ], 'id'); + } elseif ($status === 'on_site') { + $db->updateObject('#__mokosuitefield_technicians', (object) ['id' => $techId, 'status' => 'on_site'], 'id'); + } + } + } + + public static function complete(int $woId, string $workPerformed, ?string $signature = null): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + // Calculate totals from line items + $db->setQuery($db->getQuery(true) + ->select('COALESCE(SUM(CASE WHEN item_type = ' . $db->quote('labor') . ' THEN line_total ELSE 0 END), 0) AS labor') + ->select('COALESCE(SUM(CASE WHEN item_type != ' . $db->quote('labor') . ' THEN line_total ELSE 0 END), 0) AS parts') + ->from('#__mokosuitefield_wo_items') + ->where('work_order_id = ' . $woId)); + $totals = $db->loadObject(); + + $laborTotal = (float) ($totals->labor ?? 0); + $partsTotal = (float) ($totals->parts ?? 0); + $total = $laborTotal + $partsTotal; + + $db->updateObject('#__mokosuitefield_work_orders', (object) [ + 'id' => $woId, + 'status' => 'completed', + 'work_performed' => $workPerformed, + 'parts_total' => $partsTotal, + 'labor_total' => $laborTotal, + 'total' => $total, + 'customer_signature' => $signature, + 'customer_signed_at' => $signature ? $now : null, + 'actual_departure' => $now, + 'modified' => $now, + ], 'id'); + + // Update location service history + $db->setQuery($db->getQuery(true)->select('location_id')->from('#__mokosuitefield_work_orders')->where('id = ' . $woId)); + $locId = (int) $db->loadResult(); + if ($locId) { + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitefield_locations') + ->set('service_history_count = service_history_count + 1') + ->set('last_service_date = ' . $db->quote(date('Y-m-d'))) + ->where('id = ' . $locId)); + $db->execute(); + } + } + + public static function getDashboardStats(): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $today = date('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total_today') + ->select('SUM(CASE WHEN status = ' . $db->quote('new') . ' THEN 1 ELSE 0 END) AS unassigned') + ->select('SUM(CASE WHEN status IN (' . $db->quote('dispatched') . ',' . $db->quote('en_route') . ') THEN 1 ELSE 0 END) AS en_route') + ->select('SUM(CASE WHEN status IN (' . $db->quote('on_site') . ',' . $db->quote('in_progress') . ') THEN 1 ELSE 0 END) AS on_site') + ->select('SUM(CASE WHEN status = ' . $db->quote('completed') . ' THEN 1 ELSE 0 END) AS completed') + ->select('SUM(CASE WHEN priority IN (' . $db->quote('emergency') . ',' . $db->quote('urgent') . ') THEN 1 ELSE 0 END) AS urgent') + ->from('#__mokosuitefield_work_orders') + ->where('scheduled_date = ' . $db->quote($today) . ' OR (scheduled_date IS NULL AND DATE(created) = ' . $db->quote($today) . ')')); + + return $db->loadObject() ?: (object) ['total_today' => 0, 'unassigned' => 0, 'en_route' => 0, 'on_site' => 0, 'completed' => 0, 'urgent' => 0]; + } +} diff --git a/source/packages/plg_task_mokosuitefield/src/Extension/FieldAutomation.php b/source/packages/plg_task_mokosuitefield/src/Extension/FieldAutomation.php new file mode 100644 index 0000000..9fbdc96 --- /dev/null +++ b/source/packages/plg_task_mokosuitefield/src/Extension/FieldAutomation.php @@ -0,0 +1,144 @@ + [ + 'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_SERVICE_REMINDERS', + 'method' => 'sendServiceReminders', + ], + 'mokosuite.field.agreement.renewal' => [ + 'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_AGREEMENT_RENEWAL', + 'method' => 'checkAgreementRenewals', + ], + 'mokosuite.field.equipment.maintenance' => [ + 'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_EQUIPMENT_MAINTENANCE', + 'method' => 'checkEquipmentMaintenance', + ], + 'mokosuite.field.truck.reorder' => [ + 'langConstPrefix' => 'PLG_TASK_MOKOSUITEFIELD_TRUCK_REORDER', + 'method' => 'checkTruckStock', + ], + ]; + + public static function getSubscribedEvents(): array + { + return [ + 'onExecuteTask' => 'standardRoutineHandler', + 'onContentPrepareForm' => 'enhanceTaskItemForm', + 'onTaskOptionsList' => 'advertiseRoutines', + ]; + } + + private function sendServiceReminders(ExecuteTaskEvent $event): int + { + $equipment = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getDueForService(14); + + if (empty($equipment)) return Status::OK; + + $body = "Equipment due for service (next 14 days):\n\n"; + foreach ($equipment as $e) { + $body .= "- {$e->equipment_type} ({$e->make} {$e->model}) at {$e->address}, {$e->owner_name}\n"; + $body .= " Due: " . date('M j, Y', strtotime($e->next_service_date)) . "\n"; + } + + $mailer = Factory::getMailer(); + $mailer->addRecipient(Factory::getApplication()->get('mailfrom')); + $mailer->setSubject('Field Service: ' . count($equipment) . ' equipment due for maintenance'); + $mailer->setBody($body); + $mailer->Send(); + + Log::add("Field equipment reminders: " . count($equipment) . " due", Log::INFO, 'mokosuite.field'); + return Status::OK; + } + + private function checkAgreementRenewals(ExecuteTaskEvent $event): int + { + $expiring = \Moko\Plugin\System\MokoSuiteField\Helper\ServiceAgreementHelper::getExpiring(30); + + foreach ($expiring as $a) { + if (!$a->email_to) continue; + + $mailer = Factory::getMailer(); + $mailer->addRecipient($a->email_to, $a->customer_name); + $mailer->setSubject('Service Agreement Renewal — ' . $a->title); + $mailer->setBody( + "Hi {$a->customer_name},\n\n" + . "Your service agreement \"{$a->title}\" expires on " . date('F j, Y', strtotime($a->end_date)) . ".\n\n" + . "Please contact us to renew.\n" + ); + $mailer->Send(); + } + + Log::add("Field agreement renewals: " . count($expiring) . " expiring", Log::INFO, 'mokosuite.field'); + return Status::OK; + } + + private function checkEquipmentMaintenance(ExecuteTaskEvent $event): int + { + $warranty = \Moko\Plugin\System\MokoSuiteField\Helper\EquipmentHelper::getWarrantyExpiring(90); + + if (!empty($warranty)) { + $body = "Equipment warranties expiring (90 days):\n\n"; + foreach ($warranty as $e) { + $body .= "- {$e->make} {$e->model} (SN: {$e->serial_number}) — {$e->owner_name}\n"; + $body .= " Warranty expires: " . date('M j, Y', strtotime($e->warranty_expiry)) . "\n"; + } + + $mailer = Factory::getMailer(); + $mailer->addRecipient(Factory::getApplication()->get('mailfrom')); + $mailer->setSubject('Field: ' . count($warranty) . ' equipment warranties expiring'); + $mailer->setBody($body); + $mailer->Send(); + } + + return Status::OK; + } + + private function checkTruckStock(ExecuteTaskEvent $event): int + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('ts.*, p.name AS part_name, p.sku, v.vehicle_number') + ->from($db->quoteName('#__mokosuitefield_truck_stock', 'ts')) + ->join('INNER', $db->quoteName('#__mokosuite_crm_products', 'p') . ' ON p.id = ts.product_id') + ->join('INNER', $db->quoteName('#__mokosuitefield_vehicles', 'v') . ' ON v.id = ts.vehicle_id') + ->where('ts.quantity <= ts.min_quantity')); + $low = $db->loadObjectList() ?: []; + + if (!empty($low)) { + $body = "Truck stock below reorder point:\n\n"; + foreach ($low as $item) { + $body .= "- Vehicle {$item->vehicle_number}: {$item->part_name} ({$item->sku}) — {$item->quantity} remaining (min: {$item->min_quantity})\n"; + } + + $mailer = Factory::getMailer(); + $mailer->addRecipient(Factory::getApplication()->get('mailfrom')); + $mailer->setSubject('Field: ' . count($low) . ' truck stock items need reorder'); + $mailer->setBody($body); + $mailer->Send(); + } + + Log::add("Field truck stock: " . count($low) . " items low", Log::INFO, 'mokosuite.field'); + return Status::OK; + } +} diff --git a/source/packages/plg_webservices_mokosuitefield/mokosuitefield.xml b/source/packages/plg_webservices_mokosuitefield/mokosuitefield.xml deleted file mode 100644 index 718dc0d..0000000 --- a/source/packages/plg_webservices_mokosuitefield/mokosuitefield.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - Web Services - MokoSuite Field - mokosuitefield - Moko Consulting - 06.00.25 - GPL-3.0-or-later - Moko\Plugin\WebServices\MokoSuiteField - srcservices - diff --git a/source/packages/plg_webservices_mokosuitefield/services/provider.php b/source/packages/plg_webservices_mokosuitefield/services/provider.php deleted file mode 100644 index a05218a..0000000 --- a/source/packages/plg_webservices_mokosuitefield/services/provider.php +++ /dev/null @@ -1,24 +0,0 @@ - - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - -defined('_JEXEC') or die; - -use Joomla\CMS\Extension\PluginInterface; -use Joomla\CMS\Plugin\PluginHelper; -use Joomla\DI\Container; -use Joomla\DI\ServiceProviderInterface; -use Joomla\Event\DispatcherInterface; -use Moko\Plugin\WebServices\MokoSuiteField\Extension\MokoSuiteField; - -return new class implements ServiceProviderInterface { - public function register(Container $container): void { - $container->set(PluginInterface::class, function (Container $container) { - return new MokoSuiteField($container->get(DispatcherInterface::class), (array) PluginHelper::getPlugin('webservices', 'mokosuitefield')); - }); - } -}; diff --git a/source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteField.php b/source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteField.php deleted file mode 100644 index e3f8268..0000000 --- a/source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteField.php +++ /dev/null @@ -1,37 +0,0 @@ - - * SPDX-License-Identifier: GPL-3.0-or-later - * Authored-by: Moko Consulting - */ - -namespace Moko\Plugin\WebServices\MokoSuiteField\Extension; - -defined('_JEXEC') or die; - -use Joomla\CMS\Plugin\CMSPlugin; -use Joomla\Event\SubscriberInterface; - -class MokoSuiteField extends CMSPlugin implements SubscriberInterface -{ - public static function getSubscribedEvents(): array - { - return ['onBeforeApiRoute' => 'onBeforeApiRoute']; - } - - public function onBeforeApiRoute(&$event): void - { - $router = $event->getArgument('router'); - $opts = ['component' => 'com_mokosuitefield']; - - $router->createCRUDRoutes('v1/mokosuitefield/workorders', 'workorders', $opts); - $router->createCRUDRoutes('v1/mokosuitefield/equipment', 'equipment', $opts); - $router->createCRUDRoutes('v1/mokosuitefield/technicians', 'technicians', $opts); - $router->createCRUDRoutes('v1/mokosuitefield/parts', 'parts', $opts); - $router->createCRUDRoutes('v1/mokosuitefield/truckinventory', 'truckinventory', $opts); - $router->createCRUDRoutes('v1/mokosuitefield/checklists', 'checklists', $opts); - $router->createCRUDRoutes('v1/mokosuitefield/pmagreements', 'pmagreements', $opts); - $router->createCRUDRoutes('v1/mokosuitefield/dispatches', 'dispatches', $opts); - } -} diff --git a/source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteFieldApi.php b/source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteFieldApi.php new file mode 100644 index 0000000..4dfb1fa --- /dev/null +++ b/source/packages/plg_webservices_mokosuitefield/src/Extension/MokoSuiteFieldApi.php @@ -0,0 +1,27 @@ + 'onBeforeApiRoute']; + } + + public function onBeforeApiRoute(BeforeApiRouteEvent $event): void + { + $router = $event->getRouter(); + $router->createCRUDRoutes('v1/mokosuite/field/workorders', 'fieldworkorders', ['component' => 'com_mokosuitefield']); + $router->createCRUDRoutes('v1/mokosuite/field/technicians', 'fieldtechnicians', ['component' => 'com_mokosuitefield']); + $router->createCRUDRoutes('v1/mokosuite/field/equipment', 'fieldequipment', ['component' => 'com_mokosuitefield']); + $router->createCRUDRoutes('v1/mokosuite/field/agreements', 'fieldagreements', ['component' => 'com_mokosuitefield']); + $router->createCRUDRoutes('v1/mokosuite/field/estimates', 'fieldestimates', ['component' => 'com_mokosuitefield']); + $router->createCRUDRoutes('v1/mokosuite/field/locations', 'fieldlocations', ['component' => 'com_mokosuitefield']); + } +} diff --git a/source/pkg_mokosuitefield.xml b/source/pkg_mokosuitefield.xml index e0f9c37..e10a838 100644 --- a/source/pkg_mokosuitefield.xml +++ b/source/pkg_mokosuitefield.xml @@ -1,23 +1,24 @@ - Package - MokoSuite Field - mokosuitefield - 06.00.25 - 2026-06-27 - Moko Consulting - hello@mokoconsulting.tech - https://mokoconsulting.tech - Copyright (C) 2026 Moko Consulting. All rights reserved. - GNU General Public License version 3 or later; see LICENSE - Work orders, technician dispatch, equipment registry, parts inventory, PM agreements - 8.3 - - true - script.php - - plg_system_mokosuitefield.zip - - - https://git.mokoconsulting.tech/api/packages/MokoConsulting/generic/MokoSuiteField/latest/updates.xml - + Package - MokoSuite Field + mokosuitefield + 06.00.18 + 2026-06-12 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GNU General Public License version 3 or later; see LICENSE + MokoSuite Field Service - dispatch, work orders, scheduling, mobile tech. Layer 2 add-on for MokoSuite (requires CRM). + 8.3 + + true + + plg_system_mokosuitefield.zip + com_mokosuitefield.zip + plg_webservices_mokosuitefield.zip + + + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteField/updates.xml + diff --git a/source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini b/source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini deleted file mode 100644 index bdb135a..0000000 --- a/source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.ini +++ /dev/null @@ -1,13 +0,0 @@ -; Copyright (C) 2026 Moko Consulting -; SPDX-License-Identifier: GPL-3.0-or-later -; Authored-by: Moko Consulting - -PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field" -PLG_SYSTEM_MOKOSUITEFIELD_DESCRIPTION="Field service management system plugin for MokoSuite." -PLG_SYSTEM_MOKOSUITEFIELD_PARAM_COMPANY_NAME="Company Name" -PLG_SYSTEM_MOKOSUITEFIELD_PARAM_SERVICE_RADIUS="Service Radius (km)" -PLG_SYSTEM_MOKOSUITEFIELD_PARAM_AUTO_DISPATCH="Auto-dispatch" -PLG_SYSTEM_MOKOSUITEFIELD_PARAM_TIMEOUT_MINUTES="Dispatch Timeout (minutes)" -PLG_SYSTEM_MOKOSUITEFIELD_PARAM_DEFAULT_HOURLY_RATE="Default Hourly Rate" -PLG_SYSTEM_MOKOSUITEFIELD_PARAM_TAX_RATE="Tax Rate (%)" -PLG_SYSTEM_MOKOSUITEFIELD_PARAM_LOW_STOCK_THRESHOLD="Low Stock Threshold" diff --git a/source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini b/source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini deleted file mode 100644 index fcc87ca..0000000 --- a/source/plugins/system/mokosuitefield/language/en-GB/plg_system_mokosuitefield.sys.ini +++ /dev/null @@ -1,6 +0,0 @@ -; Copyright (C) 2026 Moko Consulting -; SPDX-License-Identifier: GPL-3.0-or-later -; Authored-by: Moko Consulting - -PLG_SYSTEM_MOKOSUITEFIELD="System - MokoSuite Field" -PLG_SYSTEM_MOKOSUITEFIELD_DESCRIPTION="Field service management system plugin for MokoSuite." diff --git a/source/plugins/system/mokosuitefield/mokosuitefield.xml b/source/plugins/system/mokosuitefield/mokosuitefield.xml deleted file mode 100644 index b056150..0000000 --- a/source/plugins/system/mokosuitefield/mokosuitefield.xml +++ /dev/null @@ -1,106 +0,0 @@ - - - - plg_system_mokosuitefield - 0.1.0 - 2026-06-27 - Moko Consulting - hello@mokoconsulting.tech - https://mokoconsulting.tech - Copyright (C) 2026 Moko Consulting - GPL-3.0-or-later - PLG_SYSTEM_MOKOSUITEFIELD_DESCRIPTION - - Moko\Plugin\System\MokoSuiteField - - - src - services - sql - language - - - - sql/install.sql - - - sql/uninstall.sql - - - - en-GB/plg_system_mokosuitefield.ini - en-GB/plg_system_mokosuitefield.sys.ini - - - - -
- - -
-
- - - - - -
-
- - -
-
- -
-
-
-
diff --git a/source/plugins/system/mokosuitefield/services/provider.php b/source/plugins/system/mokosuitefield/services/provider.php deleted file mode 100644 index 8f148f1..0000000 --- a/source/plugins/system/mokosuitefield/services/provider.php +++ /dev/null @@ -1,36 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -\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\System\MokoSuiteField\Extension\MokoSuiteField; - -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 MokoSuiteField( - $dispatcher, - (array) PluginHelper::getPlugin('system', 'mokosuitefield') - ); - $plugin->setApplication(Factory::getApplication()); - - return $plugin; - } - ); - } -}; diff --git a/source/plugins/system/mokosuitefield/sql/install.sql b/source/plugins/system/mokosuitefield/sql/install.sql deleted file mode 100644 index 199f5f4..0000000 --- a/source/plugins/system/mokosuitefield/sql/install.sql +++ /dev/null @@ -1,170 +0,0 @@ --- Copyright (C) 2026 Moko Consulting --- SPDX-License-Identifier: GPL-3.0-or-later --- Authored-by: Moko Consulting - -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_technicians` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `contact_id` INT DEFAULT NULL, - `name` VARCHAR(255) NOT NULL, - `email` VARCHAR(255) NOT NULL DEFAULT '', - `phone` VARCHAR(50) NOT NULL DEFAULT '', - `skills` VARCHAR(500) NOT NULL DEFAULT '', - `hourly_rate` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `status` ENUM('active','inactive','on_leave','training') NOT NULL DEFAULT 'active', - `current_lat` DECIMAL(10,7) DEFAULT NULL, - `current_lng` DECIMAL(10,7) DEFAULT NULL, - `published` TINYINT NOT NULL DEFAULT 1, - `created` DATETIME NOT NULL, - PRIMARY KEY (`id`), - KEY `idx_contact` (`contact_id`), - KEY `idx_status` (`status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_equipment` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `customer_contact_id` INT DEFAULT NULL, - `name` VARCHAR(255) NOT NULL, - `equipment_type` ENUM('hvac','plumbing','electrical','appliance','generator','elevator','fire_system','other') NOT NULL DEFAULT 'other', - `brand` VARCHAR(100) NOT NULL DEFAULT '', - `model` VARCHAR(100) NOT NULL DEFAULT '', - `serial_number` VARCHAR(100) NOT NULL DEFAULT '', - `install_date` DATE DEFAULT NULL, - `warranty_expiry` DATE DEFAULT NULL, - `location_address` VARCHAR(500) NOT NULL DEFAULT '', - `notes` TEXT, - `published` TINYINT NOT NULL DEFAULT 1, - `created` DATETIME NOT NULL, - PRIMARY KEY (`id`), - KEY `idx_customer` (`customer_contact_id`), - KEY `idx_type` (`equipment_type`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_equipment_history` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `equipment_id` INT UNSIGNED NOT NULL, - `work_order_id` INT UNSIGNED DEFAULT NULL, - `action` ENUM('install','repair','maintenance','inspection','replacement','decommission') NOT NULL DEFAULT 'maintenance', - `description` VARCHAR(500) NOT NULL DEFAULT '', - `technician_id` INT UNSIGNED DEFAULT NULL, - `action_date` DATE NOT NULL, - `cost` DECIMAL(10,2) DEFAULT NULL, - `created` DATETIME NOT NULL, - PRIMARY KEY (`id`), - KEY `idx_equipment` (`equipment_id`), - KEY `idx_date` (`action_date`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_work_orders` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `work_order_ref` VARCHAR(20) NOT NULL, - `customer_contact_id` INT DEFAULT NULL, - `customer_name` VARCHAR(255) NOT NULL, - `customer_phone` VARCHAR(50) NOT NULL DEFAULT '', - `equipment_id` INT UNSIGNED DEFAULT NULL, - `technician_id` INT UNSIGNED DEFAULT NULL, - `status` ENUM('requested','scheduled','dispatched','in_progress','on_hold','completed','invoiced','cancelled') NOT NULL DEFAULT 'requested', - `priority` ENUM('emergency','high','normal','low') NOT NULL DEFAULT 'normal', - `work_type` ENUM('repair','maintenance','installation','inspection','warranty','callback') NOT NULL DEFAULT 'repair', - `title` VARCHAR(255) NOT NULL, - `description` TEXT, - `site_address` VARCHAR(500) NOT NULL, - `site_lat` DECIMAL(10,7) DEFAULT NULL, - `site_lng` DECIMAL(10,7) DEFAULT NULL, - `scheduled_date` DATE DEFAULT NULL, - `dispatched_at` DATETIME DEFAULT NULL, - `completed_at` DATETIME DEFAULT NULL, - `labor_hours` DECIMAL(5,2) NOT NULL DEFAULT 0.00, - `labor_cost` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `parts_cost` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `total_cost` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `customer_signature` TEXT, - `notes` TEXT, - `created` DATETIME NOT NULL, - `created_by` INT NOT NULL DEFAULT 0, - PRIMARY KEY (`id`), - UNIQUE KEY `idx_ref` (`work_order_ref`), - KEY `idx_customer` (`customer_contact_id`), - KEY `idx_technician` (`technician_id`), - KEY `idx_status` (`status`), - KEY `idx_scheduled` (`scheduled_date`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_parts` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `name` VARCHAR(255) NOT NULL, - `part_number` VARCHAR(100) NOT NULL DEFAULT '', - `category` VARCHAR(100) NOT NULL DEFAULT '', - `unit_cost` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `sell_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `stock_qty` INT NOT NULL DEFAULT 0, - `reorder_level` INT UNSIGNED NOT NULL DEFAULT 5, - `supplier` VARCHAR(255) NOT NULL DEFAULT '', - `published` TINYINT NOT NULL DEFAULT 1, - `created` DATETIME NOT NULL, - PRIMARY KEY (`id`), - KEY `idx_part_number` (`part_number`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_truck_inventory` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `technician_id` INT UNSIGNED NOT NULL, - `part_id` INT UNSIGNED NOT NULL, - `quantity` INT NOT NULL DEFAULT 0, - `last_restocked` DATE DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `idx_tech_part` (`technician_id`, `part_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_checklists` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `name` VARCHAR(255) NOT NULL, - `work_type` ENUM('repair','maintenance','installation','inspection','warranty','callback','all') NOT NULL DEFAULT 'all', - `published` TINYINT NOT NULL DEFAULT 1, - `ordering` INT NOT NULL DEFAULT 0, - `created` DATETIME NOT NULL, - PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_checklist_items` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `checklist_id` INT UNSIGNED NOT NULL, - `label` VARCHAR(255) NOT NULL, - `item_type` ENUM('checkbox','text','number','photo','pass_fail') NOT NULL DEFAULT 'checkbox', - `required` TINYINT NOT NULL DEFAULT 0, - `ordering` INT NOT NULL DEFAULT 0, - PRIMARY KEY (`id`), - KEY `idx_checklist` (`checklist_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_pm_agreements` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `customer_contact_id` INT NOT NULL, - `equipment_id` INT UNSIGNED DEFAULT NULL, - `name` VARCHAR(255) NOT NULL, - `frequency` ENUM('monthly','quarterly','semi_annual','annual') NOT NULL DEFAULT 'annual', - `next_service_date` DATE DEFAULT NULL, - `annual_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00, - `status` ENUM('active','expired','cancelled') NOT NULL DEFAULT 'active', - `start_date` DATE NOT NULL, - `end_date` DATE DEFAULT NULL, - `auto_renew` TINYINT NOT NULL DEFAULT 1, - `created` DATETIME NOT NULL, - PRIMARY KEY (`id`), - KEY `idx_customer` (`customer_contact_id`), - KEY `idx_next_service` (`next_service_date`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `#__mokosuitefield_dispatches` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `work_order_id` INT UNSIGNED NOT NULL, - `technician_id` INT UNSIGNED NOT NULL, - `status` ENUM('offered','accepted','rejected','expired','cancelled') NOT NULL DEFAULT 'offered', - `offered_at` DATETIME NOT NULL, - `responded_at` DATETIME DEFAULT NULL, - `distance_km` DECIMAL(10,2) DEFAULT NULL, - `eta_minutes` DECIMAL(10,2) DEFAULT NULL, - `attempt_number` TINYINT UNSIGNED NOT NULL DEFAULT 1, - PRIMARY KEY (`id`), - KEY `idx_work_order` (`work_order_id`), - KEY `idx_technician` (`technician_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/source/plugins/system/mokosuitefield/sql/uninstall.sql b/source/plugins/system/mokosuitefield/sql/uninstall.sql deleted file mode 100644 index d919198..0000000 --- a/source/plugins/system/mokosuitefield/sql/uninstall.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Copyright (C) 2026 Moko Consulting --- SPDX-License-Identifier: GPL-3.0-or-later --- Authored-by: Moko Consulting - -DROP TABLE IF EXISTS `#__mokosuitefield_dispatches`; -DROP TABLE IF EXISTS `#__mokosuitefield_pm_agreements`; -DROP TABLE IF EXISTS `#__mokosuitefield_checklist_items`; -DROP TABLE IF EXISTS `#__mokosuitefield_checklists`; -DROP TABLE IF EXISTS `#__mokosuitefield_truck_inventory`; -DROP TABLE IF EXISTS `#__mokosuitefield_parts`; -DROP TABLE IF EXISTS `#__mokosuitefield_work_orders`; -DROP TABLE IF EXISTS `#__mokosuitefield_equipment_history`; -DROP TABLE IF EXISTS `#__mokosuitefield_equipment`; -DROP TABLE IF EXISTS `#__mokosuitefield_technicians`; diff --git a/source/plugins/system/mokosuitefield/src/Extension/MokoSuiteField.php b/source/plugins/system/mokosuitefield/src/Extension/MokoSuiteField.php deleted file mode 100644 index b7627a0..0000000 --- a/source/plugins/system/mokosuitefield/src/Extension/MokoSuiteField.php +++ /dev/null @@ -1,23 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -namespace Moko\Plugin\System\MokoSuiteField\Extension; - -use Joomla\CMS\Plugin\CMSPlugin; -use Joomla\Event\DispatcherInterface; -use Joomla\Event\SubscriberInterface; - -\defined('_JEXEC') or die; - -final class MokoSuiteField extends CMSPlugin implements SubscriberInterface -{ - public static function getSubscribedEvents(): array - { - return []; - } -} diff --git a/source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.ini b/source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.ini deleted file mode 100644 index 38d8d0d..0000000 --- a/source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.ini +++ /dev/null @@ -1,6 +0,0 @@ -; Copyright (C) 2026 Moko Consulting -; SPDX-License-Identifier: GPL-3.0-or-later -; Authored-by: Moko Consulting - -PLG_WEBSERVICES_MOKOSUITEFIELD="Web Services - MokoSuite Field" -PLG_WEBSERVICES_MOKOSUITEFIELD_DESCRIPTION="Provides API routes for MokoSuite Field service management." diff --git a/source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.sys.ini b/source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.sys.ini deleted file mode 100644 index 38d8d0d..0000000 --- a/source/plugins/webservices/mokosuitefield/language/en-GB/plg_webservices_mokosuitefield.sys.ini +++ /dev/null @@ -1,6 +0,0 @@ -; Copyright (C) 2026 Moko Consulting -; SPDX-License-Identifier: GPL-3.0-or-later -; Authored-by: Moko Consulting - -PLG_WEBSERVICES_MOKOSUITEFIELD="Web Services - MokoSuite Field" -PLG_WEBSERVICES_MOKOSUITEFIELD_DESCRIPTION="Provides API routes for MokoSuite Field service management." diff --git a/source/plugins/webservices/mokosuitefield/mokosuitefield.xml b/source/plugins/webservices/mokosuitefield/mokosuitefield.xml deleted file mode 100644 index 63c2610..0000000 --- a/source/plugins/webservices/mokosuitefield/mokosuitefield.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - plg_webservices_mokosuitefield - 0.1.0 - 2026-06-27 - Moko Consulting - hello@mokoconsulting.tech - https://mokoconsulting.tech - Copyright (C) 2026 Moko Consulting - GPL-3.0-or-later - PLG_WEBSERVICES_MOKOSUITEFIELD_DESCRIPTION - - Moko\Plugin\WebServices\MokoSuiteField - - - src - services - language - - - - en-GB/plg_webservices_mokosuitefield.ini - en-GB/plg_webservices_mokosuitefield.sys.ini - - diff --git a/source/plugins/webservices/mokosuitefield/services/provider.php b/source/plugins/webservices/mokosuitefield/services/provider.php deleted file mode 100644 index f46086f..0000000 --- a/source/plugins/webservices/mokosuitefield/services/provider.php +++ /dev/null @@ -1,36 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -\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\WebServices\MokoSuiteField\Extension\MokoSuiteField; - -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 MokoSuiteField( - $dispatcher, - (array) PluginHelper::getPlugin('webservices', 'mokosuitefield') - ); - $plugin->setApplication(Factory::getApplication()); - - return $plugin; - } - ); - } -}; diff --git a/source/plugins/webservices/mokosuitefield/src/Extension/MokoSuiteField.php b/source/plugins/webservices/mokosuitefield/src/Extension/MokoSuiteField.php deleted file mode 100644 index ce2562f..0000000 --- a/source/plugins/webservices/mokosuitefield/src/Extension/MokoSuiteField.php +++ /dev/null @@ -1,50 +0,0 @@ - - * @license GPL-3.0-or-later - * @author Moko Consulting - */ - -namespace Moko\Plugin\WebServices\MokoSuiteField\Extension; - -use Joomla\CMS\Plugin\CMSPlugin; -use Joomla\CMS\Router\ApiRouter; -use Joomla\Event\SubscriberInterface; -use Joomla\Router\Route; - -\defined('_JEXEC') or die; - -final class MokoSuiteField extends CMSPlugin implements SubscriberInterface -{ - public static function getSubscribedEvents(): array - { - return [ - 'onBeforeApiRoute' => 'onBeforeApiRoute', - ]; - } - - public function onBeforeApiRoute(&$router): void - { - $routes = [ - 'workorders' => 'fieldworkorders', - 'technicians' => 'fieldtechnicians', - 'equipment' => 'fieldequipment', - 'parts' => 'fieldparts', - 'checklists' => 'fieldchecklists', - 'agreements' => 'fieldagreements', - 'dispatches' => 'fielddispatches', - ]; - - $component = 'com_mokosuitefield'; - $defaults = ['component' => $component, 'public' => false]; - - foreach ($routes as $endpoint => $view) { - $router->createCRUDRoutes( - "v1/mokosuitefield/{$endpoint}", - $view, - $defaults - ); - } - } -} diff --git a/source/script.php b/source/script.php deleted file mode 100644 index 50fddcc..0000000 --- a/source/script.php +++ /dev/null @@ -1,166 +0,0 @@ -missingChildExtensions($adapter); - - if (!empty($missing)) - { - Factory::getApplication()->enqueueMessage( - '

MokoSuiteField did not install correctly.

' - . '

The following bundled extensions are missing: ' - . htmlspecialchars(implode(', ', $missing), ENT_QUOTES) . '

' - . '

Please uninstall MokoSuiteField and reinstall the full package.

', - 'error' - ); - - return; - } - - $this->warnMissingLicenseKey(); - } - - /** - * Verify every child extension declared in the package manifest actually landed in - * #__extensions. Returns readable labels of any that are missing. - * - * Matching per Joomla's #__extensions uniqueness: type = "type"; element = - * "id" (for plugins, strip a leading plg__); plugins also match - * folder = "group". - * - * FAILS OPEN: any error returns [] so a transient glitch never fakes a failure. - */ - private function missingChildExtensions($adapter): array - { - try - { - $manifest = $adapter->getParent()->getManifest(); - - if (!$manifest || !isset($manifest->files) || !isset($manifest->files->file)) - { - return []; - } - - $db = Factory::getDbo(); - $missing = []; - - foreach ($manifest->files->file as $file) - { - $attrs = $file->attributes(); - $id = isset($attrs['id']) ? (string) $attrs['id'] : ''; - $exType = isset($attrs['type']) ? (string) $attrs['type'] : ''; - - if ($id === '' || $exType === '') - { - continue; - } - - $group = isset($attrs['group']) ? (string) $attrs['group'] : ''; - $element = $id; - - // Plugin element in #__extensions is the id minus any leading plg__. - if ($exType === 'plugin' && $group !== '') - { - $prefix = 'plg_' . $group . '_'; - - if (strpos($element, $prefix) === 0) - { - $element = substr($element, \strlen($prefix)); - } - } - - $query = $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('element') . ' = ' . $db->quote($element)) - ->where($db->quoteName('type') . ' = ' . $db->quote($exType)); - - if ($exType === 'plugin' && $group !== '') - { - $query->where($db->quoteName('folder') . ' = ' . $db->quote($group)); - } - - if ((int) $db->setQuery($query)->loadResult() === 0) - { - $label = trim((string) $file); - $missing[] = $label !== '' ? preg_replace('/\.zip$/i', '', $label) : $id; - } - } - - return $missing; - } - catch (\Throwable $e) - { - // Fail open — never fake a failure on a glitch. - return []; - } - } - - private function warnMissingLicenseKey(): void - { - try - { - $db = Factory::getDbo(); - $app = Factory::getApplication(); - - $query = $db->getQuery(true) - ->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')]) - ->from($db->quoteName('#__update_sites')) - ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoSuiteField%') - . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoSuiteField%') . ')') - ->setLimit(1); - $db->setQuery($query); - $site = $db->loadObject(); - - if ($site) - { - $extraQuery = (string) ($site->extra_query ?? ''); - - if (!empty($extraQuery) && strpos($extraQuery, 'dlid=') !== false) - { - parse_str($extraQuery, $parsed); - - if (!empty($parsed['dlid'])) - { - return; - } - } - - $editUrl = 'index.php?option=com_installer&task=updatesite.edit&update_site_id=' . (int) $site->update_site_id; - } - else - { - $editUrl = 'index.php?option=com_installer&view=updatesites'; - } - - $app->enqueueMessage( - 'Moko Consulting License Key Required — ' - . 'No download key is configured. Updates will not be available until a valid license key is entered. ' - . 'Enter License Key', - 'warning' - ); - } - catch (\Throwable $e) - { - // Silent — avoid breaking install if update_sites query fails - } - } -} diff --git a/source/updates.xml b/source/updates.xml new file mode 100644 index 0000000..39bccee --- /dev/null +++ b/source/updates.xml @@ -0,0 +1,9 @@ + + +Package - MokoSuite Field +pkg_mokosuitefield +package +01.01.00 + +8.3 +