Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 903fa0fe57 | |||
| 3c120d70fa | |||
| 226f048cb9 | |||
| 03d49953ba | |||
| ba88d8e3b2 | |||
| c5afe4529d | |||
| 9ba3c26ea2 | |||
| cd8ef1c1a6 | |||
| 08c2974ce7 | |||
| 9b01493ed9 | |||
| 7892fdb8d9 | |||
| 397c4e022d | |||
| 133abdd582 | |||
| 8e4680824d | |||
| 504452e7ac | |||
| 408516c305 | |||
| 3a4c018155 | |||
| 85bad54190 | |||
| daa4074cb8 | |||
| 8d9ccbe448 | |||
| 69fde3f532 | |||
| 598ba77422 | |||
| f026386c26 | |||
| 6c7740da8c | |||
| c22748b0ac | |||
| e4f00dfa66 | |||
| 7229b4cae4 | |||
| 77f08380e2 | |||
| 5a498cf3f6 | |||
| 9b5060f772 | |||
| 6f29a17b95 | |||
| 627ed7e48f | |||
| c996eaca2b | |||
| 2069d499fe | |||
| feac684898 | |||
| 267fa178df | |||
| cc36efc60d | |||
| 7aaa41048d | |||
| 65fcfd03d2 | |||
| 36a8f96beb | |||
| 4ee155c8f3 | |||
| 63ffd7ea28 | |||
| d7e6ec338e | |||
| 162298f8f9 | |||
| be03793478 |
@@ -0,0 +1,31 @@
|
|||||||
|
# EditorConfig https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.ps1]
|
||||||
|
end_of_line = crlf
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{json,yml,yaml}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{mak,Makefile}]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.bat]
|
||||||
|
end_of_line = crlf
|
||||||
|
|
||||||
|
[*.sh]
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
*.min.css
|
||||||
|
*.min.js
|
||||||
|
node_modules/
|
||||||
|
.claude/
|
||||||
|
.mcp.json
|
||||||
|
TODO.md
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
@@ -4,3 +4,6 @@
|
|||||||
[submodule "packages/MokoSuiteCRM"]
|
[submodule "packages/MokoSuiteCRM"]
|
||||||
path = packages/MokoSuiteCRM
|
path = packages/MokoSuiteCRM
|
||||||
url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM.git
|
url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM.git
|
||||||
|
[submodule "packages/MokoSuiteERP"]
|
||||||
|
path = packages/MokoSuiteERP
|
||||||
|
url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteERP.git
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: Documentation Issue
|
||||||
|
about: Report an issue with documentation
|
||||||
|
title: '[DOCS] '
|
||||||
|
labels: 'documentation'
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Documentation Issue
|
||||||
|
|
||||||
|
**Location**:
|
||||||
|
<!-- Specify the file, page, or section with the issue -->
|
||||||
|
|
||||||
|
## Issue Type
|
||||||
|
<!-- Mark the relevant option with an "x" -->
|
||||||
|
- [ ] Typo or grammar error
|
||||||
|
- [ ] Outdated information
|
||||||
|
- [ ] Missing documentation
|
||||||
|
- [ ] Unclear explanation
|
||||||
|
- [ ] Broken links
|
||||||
|
- [ ] Missing examples
|
||||||
|
- [ ] Other (specify below)
|
||||||
|
|
||||||
|
## Description
|
||||||
|
<!-- Clearly describe the documentation issue -->
|
||||||
|
|
||||||
|
## Current Content
|
||||||
|
<!-- Quote or describe the current documentation (if applicable) -->
|
||||||
|
```
|
||||||
|
Current text here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Suggested Improvement
|
||||||
|
<!-- Provide your suggestion for how to improve the documentation -->
|
||||||
|
```
|
||||||
|
Suggested text here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
<!-- Add any other context, screenshots, or references -->
|
||||||
|
|
||||||
|
## 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)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
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)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
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**:
|
||||||
|
<!-- Low, Medium, or informational only -->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
<!-- Describe the security concern or improvement suggestion -->
|
||||||
|
|
||||||
|
## Affected Components
|
||||||
|
<!-- List the affected files, features, or components -->
|
||||||
|
|
||||||
|
## Suggested Mitigation
|
||||||
|
<!-- Describe how this could be addressed -->
|
||||||
|
|
||||||
|
## 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
|
||||||
|
<!-- Add any other context about the security concern -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] This is NOT a critical vulnerability requiring private disclosure
|
||||||
|
- [ ] I have reviewed the SECURITY.md policy
|
||||||
|
- [ ] I have provided sufficient detail for evaluation
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: Version Bump
|
||||||
|
about: Request or track a version change
|
||||||
|
title: '[VERSION] '
|
||||||
|
labels: 'version, type: version'
|
||||||
|
assignees: 'jmiller'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version Change
|
||||||
|
|
||||||
|
**Current version**: <!-- e.g., 01.02.03 -->
|
||||||
|
**Requested version**: <!-- e.g., 01.03.00 -->
|
||||||
|
**Change type**: <!-- patch / minor / major -->
|
||||||
|
|
||||||
|
## Reason
|
||||||
|
|
||||||
|
<!-- Why is this version bump needed? -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] README.md `VERSION:` field updated
|
||||||
|
- [ ] CHANGELOG.md entry added
|
||||||
|
- [ ] Module descriptor version updated (Dolibarr: `$this->version`, Joomla: `<version>`)
|
||||||
|
- [ ] All file headers will be auto-propagated by `sync-version-on-merge` workflow
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,903 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# 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 <extension tag)
|
||||||
|
MANIFEST=""
|
||||||
|
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||||
|
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||||
|
MANIFEST="$XML_FILE"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$MANIFEST" ]; then
|
||||||
|
echo "No Joomla extension manifest found (XML file with \`<extension\` tag)." >> $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 '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||||
|
if [ "$EXT_TYPE" != "package" ]; then
|
||||||
|
if ! grep -q "<namespace" "$MANIFEST" 2>/dev/null; then
|
||||||
|
echo "Missing required tag: \`<namespace>\` (required for Joomla 5+ ${EXT_TYPE} extensions)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Found required tag: \`<namespace>\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Package extension — \`<namespace>\` 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 "<extension" "$XML_FILE" 2>/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 '<extension[^>]*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 "<extension" "$XML_FILE" 2>/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 <filename> references
|
||||||
|
FILENAMES=$(grep -oP '<filename[^>]*>\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 <folder> references
|
||||||
|
FOLDERS=$(grep -oP '<folder[^>]*>\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 <file> references in package manifests (ZIP files won't exist in source)
|
||||||
|
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||||
|
if [ "$EXT_TYPE" != "package" ]; then
|
||||||
|
FILES=$(grep -oP '<file[^>]*>\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 '<form|<field|<fieldset' "$FILE" 2>/dev/null; then
|
||||||
|
echo "- \`${FILE}\`: no \`<form>\`, \`<field>\`, or \`<fieldset>\` 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: <?= $var ?> 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 \`<?= \$var ?>\` output(s) — use \`<?= \$this->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 <namespace> tags
|
||||||
|
MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<namespace' {} \; 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -z "$MANIFESTS" ]; then
|
||||||
|
echo "No manifests with \`<namespace>\` found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
for MANIFEST in $MANIFESTS; do
|
||||||
|
NS_PATH=$(grep -oP '<namespace[^>]*>\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 "<extension" "$XML_FILE" 2>/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 <version> matches README VERSION
|
||||||
|
MANIFEST_VERSION=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
|
||||||
|
if [ -z "$MANIFEST_VERSION" ]; then
|
||||||
|
echo "No \`<version>\` 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 '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||||
|
if [ -z "$EXT_TYPE" ]; then
|
||||||
|
echo "Missing \`type\` attribute on \`<extension>\` 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 \`<element>\` or \`<name>\` 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 '<extension[^>]*\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
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
name: "Publish to Composer"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
- '[0-9]*.[0-9]*.[0-9]*'
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
name: Publish Package
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: >-
|
|
||||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
|
||||||
!contains(github.event.head_commit.message, '[skip publish]')
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
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
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: composer install --no-dev --no-interaction --prefer-dist --quiet
|
|
||||||
|
|
||||||
- name: Determine version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
|
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Package version: ${VERSION}"
|
|
||||||
|
|
||||||
# Gitea Composer Registry — auto-publishes from tags
|
|
||||||
# The tag push itself registers the package at:
|
|
||||||
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
|
|
||||||
- name: Verify Gitea registry
|
|
||||||
run: |
|
|
||||||
echo "Gitea Composer registry auto-publishes from tags."
|
|
||||||
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
|
|
||||||
echo "Install: composer require mokoconsulting/mokocli"
|
|
||||||
|
|
||||||
# Packagist — notify of new version
|
|
||||||
- name: Notify Packagist
|
|
||||||
if: secrets.PACKAGIST_TOKEN != ''
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
|
||||||
echo "Notifying Packagist of version ${VERSION}..."
|
|
||||||
curl -sf -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
|
|
||||||
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
|
|
||||||
&& echo "Packagist notified" \
|
|
||||||
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
|
|
||||||
|
|
||||||
- name: Summary
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
|
||||||
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: MokoStandards.Deploy
|
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
|
||||||
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
|
|
||||||
# VERSION: 04.07.00
|
|
||||||
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
|
|
||||||
|
|
||||||
name: "Universal: Deploy to Dev (Manual)"
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
clear_remote:
|
|
||||||
description: 'Delete all remote files before uploading'
|
|
||||||
required: false
|
|
||||||
default: 'false'
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: SFTP Deploy to Dev
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
run: |
|
|
||||||
php -v && composer --version
|
|
||||||
|
|
||||||
- name: Setup MokoStandards tools
|
|
||||||
env:
|
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
|
||||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
|
||||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
|
||||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
|
||||||
run: |
|
|
||||||
git clone --depth 1 --branch main --quiet \
|
|
||||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
|
||||||
/tmp/mokostandards-api 2>/dev/null || true
|
|
||||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
|
||||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Check FTP configuration
|
|
||||||
id: check
|
|
||||||
env:
|
|
||||||
HOST: ${{ vars.DEV_FTP_HOST }}
|
|
||||||
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
|
|
||||||
PORT: ${{ vars.DEV_FTP_PORT }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
|
|
||||||
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
|
|
||||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "host=$HOST" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
REMOTE="${PATH_VAR%/}"
|
|
||||||
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
[ -z "$PORT" ] && PORT="22"
|
|
||||||
echo "port=$PORT" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Deploy via SFTP
|
|
||||||
if: steps.check.outputs.skip != 'true'
|
|
||||||
env:
|
|
||||||
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
|
|
||||||
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
|
||||||
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
|
|
||||||
run: |
|
|
||||||
SOURCE_DIR="src"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
|
|
||||||
|
|
||||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
|
||||||
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
|
|
||||||
> /tmp/sftp-config.json
|
|
||||||
|
|
||||||
if [ -n "$SFTP_KEY" ]; then
|
|
||||||
echo "$SFTP_KEY" > /tmp/deploy_key
|
|
||||||
chmod 600 /tmp/deploy_key
|
|
||||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
|
||||||
else
|
|
||||||
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
|
|
||||||
fi
|
|
||||||
|
|
||||||
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
|
||||||
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
|
|
||||||
|
|
||||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
|
||||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
|
||||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
|
||||||
else
|
|
||||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
|
||||||
|
|
||||||
- name: Summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
|
|
||||||
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokocli.Automation
|
# INGROUP: mokocli.Automation
|
||||||
# VERSION: 01.08.00
|
# VERSION: 06.00.09
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: mokocli.Validation
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
|
# PATH: /templates/workflows/joomla/pr-metadata-check.yml.template
|
||||||
|
# VERSION: 01.00.00
|
||||||
|
# BRIEF: Validate MokoGitea metadata matches Joomla extension manifest on PRs
|
||||||
|
|
||||||
|
name: "Joomla: Metadata Validation"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, converted_to_draft, ready_for_review]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_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 }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-metadata:
|
||||||
|
name: "Validate Joomla Metadata"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup mokocli tools
|
||||||
|
env:
|
||||||
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
|
run: |
|
||||||
|
if [ -f /opt/mokocli/cli/joomla_metadata_validate.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||||
|
echo Using pre-installed /opt/mokocli
|
||||||
|
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo Falling back to fresh clone
|
||||||
|
if ! command -v composer > /dev/null 2>&1; 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
|
||||||
|
rm -rf /tmp/mokocli
|
||||||
|
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||||
|
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||||
|
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||||
|
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Validate metadata against Joomla manifest
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
php ${MOKO_CLI}/joomla_metadata_validate.php \
|
||||||
|
--path . \
|
||||||
|
--token "${GITEA_TOKEN}" \
|
||||||
|
--org "${GITEA_ORG}" \
|
||||||
|
--repo "${GITEA_REPO}" \
|
||||||
|
--api-base "${GITEA_URL}/api/v1" \
|
||||||
|
--ci
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "::error::Joomla metadata mismatch — update delivery will fail. Run 'php cli/joomla_metadata_validate.php' locally to see details."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: MokoStandards.Security
|
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
|
||||||
# PATH: /.gitea/workflows/security-audit.yml
|
|
||||||
# VERSION: 01.00.00
|
|
||||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
|
||||||
|
|
||||||
name: "Universal: Security Audit"
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- 'composer.json'
|
|
||||||
- 'composer.lock'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
|
||||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
|
||||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
audit:
|
|
||||||
name: Dependency Audit
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Composer audit
|
|
||||||
if: hashFiles('composer.lock') != ''
|
|
||||||
run: |
|
|
||||||
echo "=== Composer Security Audit ==="
|
|
||||||
if ! command -v composer &> /dev/null; then
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
|
|
||||||
RESULT=$?
|
|
||||||
if [ $RESULT -ne 0 ]; then
|
|
||||||
echo "::warning::Composer vulnerabilities found"
|
|
||||||
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
|
|
||||||
else
|
|
||||||
echo "No known vulnerabilities in composer dependencies"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: NPM audit
|
|
||||||
if: hashFiles('package-lock.json') != ''
|
|
||||||
run: |
|
|
||||||
echo "=== NPM Security Audit ==="
|
|
||||||
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
|
|
||||||
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
|
|
||||||
echo "No known vulnerabilities in npm dependencies"
|
|
||||||
else
|
|
||||||
echo "::warning::NPM vulnerabilities found"
|
|
||||||
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Notify on vulnerabilities
|
|
||||||
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
|
|
||||||
run: |
|
|
||||||
REPO="${{ github.event.repository.name }}"
|
|
||||||
curl -sS \
|
|
||||||
-H "Title: ${REPO} has vulnerable dependencies" \
|
|
||||||
-H "Tags: lock,warning" \
|
|
||||||
-H "Priority: high" \
|
|
||||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
|
||||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
|
||||||
+18
-9
@@ -1,12 +1,21 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
INGROUP: MokoSuiteField.Documentation
|
||||||
|
BRIEF: Version history using Keep a Changelog
|
||||||
|
-->
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [01.01.00] - 2026-06-12
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Initial scaffold: field service management for MokoSuite
|
- **Repository** -- initial repo creation with scaffolding
|
||||||
- 12 database tables for technicians, work orders, service agreements, equipment, vehicles, estimates
|
- **System Plugin** -- Extension class, service provider
|
||||||
- 7 helpers: Dispatch, WorkOrder, ServiceAgreement, Equipment, Estimate, TruckStock, Vehicle
|
- **SQL Schema** -- 10 tables: work_orders, equipment, equipment_history, technicians, parts, truck_inventory, work_order_parts, checklists, pm_agreements, dispatches
|
||||||
- Admin views: Dashboard, Work Orders, Technicians, Service Agreements, Equipment, Dispatch, Vehicles
|
- **Admin Component** -- 9 views: dashboard, work orders, equipment, technicians, parts, truck inventory, checklists, PM agreements, dispatches
|
||||||
- Site views: Tech Mobile (tablet), Book Service (public form)
|
- **Webservices Plugin** -- API route stubs
|
||||||
- API controller with 6 endpoints
|
- **Configuration** -- settings across basic, dispatch, billing, scheduling
|
||||||
- Task scheduler: service reminders, agreement renewals, equipment warranty, truck stock reorder
|
- **Access Control** -- permissions for granular role-based access
|
||||||
- Joomla 6 architecture (PHP 8.3+)
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
SPDX-License-Identifier: CC-BY-4.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behaviour may be
|
||||||
|
reported by contacting the project team at
|
||||||
|
[info@mokoconsulting.tech](mailto:info@mokoconsulting.tech).
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the
|
||||||
|
[Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## Branching Workflow
|
||||||
|
|
||||||
|
We use a **stability-gated** branching model:
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/* ──── PR ───→ dev
|
||||||
|
│ RC cut → rc → main
|
||||||
|
fix/* ───────── PR ────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Create a branch** from `dev`:
|
||||||
|
- `feature/<short-name>` for new functionality
|
||||||
|
- `fix/<short-name>` for bug fixes
|
||||||
|
- `chore/<short-name>` for maintenance
|
||||||
|
2. **Open a PR** into `dev`.
|
||||||
|
3. **CI must pass** before merge.
|
||||||
|
4. **Release cuts**: `dev → rc → main` are handled by maintainers.
|
||||||
|
|
||||||
|
> **Never commit directly to `main` or `dev`.**
|
||||||
|
|
||||||
|
## Version Policy
|
||||||
|
|
||||||
|
All repositories use the **XX.YY.ZZ** versioning scheme (two-digit segments):
|
||||||
|
|
||||||
|
- `XX` -- major (breaking changes)
|
||||||
|
- `YY` -- minor (new features, backward-compatible)
|
||||||
|
- `ZZ` -- patch (bug fixes, security patches)
|
||||||
|
|
||||||
|
**Stability suffixes** may be appended during pre-release:
|
||||||
|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
## Auto-Bump
|
||||||
|
|
||||||
|
Version bumps are **automated** via the `auto-bump` workflow:
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
We follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <short description>
|
||||||
|
|
||||||
|
<optional body>
|
||||||
|
|
||||||
|
<optional footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`
|
||||||
|
|
||||||
|
## Pull Request Checklist
|
||||||
|
|
||||||
|
Before submitting a PR, ensure:
|
||||||
|
|
||||||
|
- [ ] 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
|
||||||
|
|
||||||
|
## 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).
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Governance
|
||||||
|
|
||||||
|
## Project Leadership
|
||||||
|
|
||||||
|
This repository is maintained by **Moko Consulting** under a **sole operator** model.
|
||||||
|
|
||||||
|
- **Lead Maintainer**: Jonathan Miller (@jmiller)
|
||||||
|
- **Organisation**: [Moko Consulting](https://mokoconsulting.tech)
|
||||||
|
|
||||||
|
## Decision Making
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
All participants must adhere to our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
|
## Licensing
|
||||||
|
|
||||||
|
All contributions are licensed under the same license as the project
|
||||||
|
(GPL-3.0-or-later unless otherwise stated in the repository root).
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Security vulnerabilities should be reported privately.
|
||||||
|
See [SECURITY.md](SECURITY.md) for details.
|
||||||
|
|
||||||
|
## Dispute Resolution
|
||||||
|
|
||||||
|
Disputes are resolved by the lead maintainer. For escalation,
|
||||||
|
contact [info@mokoconsulting.tech](mailto:info@mokoconsulting.tech).
|
||||||
|
|
||||||
|
## Changes to Governance
|
||||||
|
|
||||||
|
This document may be updated at any time by the lead maintainer.
|
||||||
|
Significant changes will be announced via an RFC issue.
|
||||||
@@ -1,3 +1,44 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
# MokoSuite Field
|
# MokoSuite Field
|
||||||
|
|
||||||
MokoSuite Field Service - dispatch, work orders, scheduling, mobile tech, plumbing, electrical, HVAC, service agreements. Layer 2 add-on for MokoSuite (requires CRM).
|
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)
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---|---|
|
||||||
|
| Latest stable | ✅ Full support |
|
||||||
|
| Previous major | ⚠️ Critical fixes only |
|
||||||
|
| Older | ❌ No support |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
**Do not report security vulnerabilities via public issues.**
|
||||||
|
|
||||||
|
Instead, please report them privately:
|
||||||
|
|
||||||
|
1. **Email**: [security@mokoconsulting.tech](mailto:security@mokoconsulting.tech)
|
||||||
|
2. **Subject**: `[SECURITY] <Repository Name> - <Brief Description>`
|
||||||
|
|
||||||
|
### What to Include
|
||||||
|
|
||||||
|
- Description of the vulnerability
|
||||||
|
- Steps to reproduce
|
||||||
|
- Affected versions
|
||||||
|
- Potential impact
|
||||||
|
- Suggested fix (if any)
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
|
## Remediation Timeline
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
### For Contributors
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
|
||||||
|
- Keep Joomla and all extensions updated
|
||||||
|
- Use strong, unique passwords
|
||||||
|
- Enable two-factor authentication
|
||||||
|
- Review file permissions regularly
|
||||||
|
- Monitor Joomla error logs
|
||||||
|
|
||||||
|
## Security Updates
|
||||||
|
|
||||||
|
Security patches are delivered through the standard update channel.
|
||||||
|
Critical fixes may receive an emergency out-of-band release.
|
||||||
|
|
||||||
|
## Responsible Disclosure
|
||||||
|
|
||||||
|
We follow coordinated disclosure practices:
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
- **Security team**: [security@mokoconsulting.tech](mailto:security@mokoconsulting.tech)
|
||||||
|
- **General**: [hello@mokoconsulting.tech](mailto:hello@mokoconsulting.tech)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Thank you for helping keep Moko Consulting projects secure.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Submodule
+1
Submodule packages/MokoSuiteERP added at 8c76969ee8
@@ -0,0 +1,20 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Config.StaticAnalysis
|
||||||
|
# INGROUP: Development
|
||||||
|
# BRIEF: PHPStan configuration for Joomla extension static analysis
|
||||||
|
|
||||||
|
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#'
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
Authored-by: Moko Consulting
|
||||||
|
-->
|
||||||
|
<access component="com_mokosuitefield">
|
||||||
|
<section name="component">
|
||||||
|
<action name="core.admin" title="JACTION_ADMIN" />
|
||||||
|
<action name="core.options" title="JACTION_OPTIONS" />
|
||||||
|
<action name="core.manage" title="JACTION_MANAGE" />
|
||||||
|
<action name="core.create" title="JACTION_CREATE" />
|
||||||
|
<action name="core.delete" title="JACTION_DELETE" />
|
||||||
|
<action name="core.edit" title="JACTION_EDIT" />
|
||||||
|
<action name="core.edit.state" title="JACTION_EDITSTATE" />
|
||||||
|
</section>
|
||||||
|
</access>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
Authored-by: Moko Consulting
|
||||||
|
-->
|
||||||
|
<extension type="component" method="upgrade">
|
||||||
|
<name>com_mokosuitefield</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<creationDate>2026-06-27</creationDate>
|
||||||
|
<author>Moko Consulting</author>
|
||||||
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
|
<copyright>Copyright (C) 2026 Moko Consulting</copyright>
|
||||||
|
<license>GPL-3.0-or-later</license>
|
||||||
|
<description>COM_MOKOSUITEFIELD_DESCRIPTION</description>
|
||||||
|
|
||||||
|
<namespace path="src">Moko\Component\MokoSuiteField</namespace>
|
||||||
|
|
||||||
|
<administration>
|
||||||
|
<menu>COM_MOKOSUITEFIELD</menu>
|
||||||
|
<submenu>
|
||||||
|
<menu link="option=com_mokosuitefield&view=fielddashboard" view="fielddashboard" img="icon-home">COM_MOKOSUITEFIELD_MENU_DASHBOARD</menu>
|
||||||
|
<menu link="option=com_mokosuitefield&view=fieldworkorders" view="fieldworkorders" img="icon-file-2">COM_MOKOSUITEFIELD_MENU_WORKORDERS</menu>
|
||||||
|
<menu link="option=com_mokosuitefield&view=fieldtechnicians" view="fieldtechnicians" img="icon-users">COM_MOKOSUITEFIELD_MENU_TECHNICIANS</menu>
|
||||||
|
<menu link="option=com_mokosuitefield&view=fieldequipment" view="fieldequipment" img="icon-cogs">COM_MOKOSUITEFIELD_MENU_EQUIPMENT</menu>
|
||||||
|
<menu link="option=com_mokosuitefield&view=fieldparts" view="fieldparts" img="icon-cube">COM_MOKOSUITEFIELD_MENU_PARTS</menu>
|
||||||
|
<menu link="option=com_mokosuitefield&view=fieldchecklists" view="fieldchecklists" img="icon-checklist">COM_MOKOSUITEFIELD_MENU_CHECKLISTS</menu>
|
||||||
|
<menu link="option=com_mokosuitefield&view=fieldagreements" view="fieldagreements" img="icon-contract">COM_MOKOSUITEFIELD_MENU_AGREEMENTS</menu>
|
||||||
|
<menu link="option=com_mokosuitefield&view=fielddispatches" view="fielddispatches" img="icon-location">COM_MOKOSUITEFIELD_MENU_DISPATCHES</menu>
|
||||||
|
</submenu>
|
||||||
|
<files folder=".">
|
||||||
|
<folder>src</folder>
|
||||||
|
<folder>tmpl</folder>
|
||||||
|
<folder>services</folder>
|
||||||
|
<folder>language</folder>
|
||||||
|
<filename>access.xml</filename>
|
||||||
|
<filename>config.xml</filename>
|
||||||
|
</files>
|
||||||
|
</administration>
|
||||||
|
|
||||||
|
<languages folder="language">
|
||||||
|
<language tag="en-GB">en-GB/com_mokosuitefield.ini</language>
|
||||||
|
<language tag="en-GB">en-GB/com_mokosuitefield.sys.ini</language>
|
||||||
|
</languages>
|
||||||
|
</extension>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
Authored-by: Moko Consulting
|
||||||
|
-->
|
||||||
|
<config>
|
||||||
|
<fieldset name="permissions" label="JCONFIG_PERMISSIONS_LABEL" description="JCONFIG_PERMISSIONS_DESC">
|
||||||
|
<field
|
||||||
|
name="rules"
|
||||||
|
type="rules"
|
||||||
|
label="JCONFIG_PERMISSIONS_LABEL"
|
||||||
|
validate="rules"
|
||||||
|
filter="rules"
|
||||||
|
component="com_mokosuitefield"
|
||||||
|
section="component"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</config>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
; Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
; 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"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
; Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
; 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"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
* @author Moko Consulting
|
||||||
|
*/
|
||||||
|
|
||||||
|
\defined('_JEXEC') or die;
|
||||||
|
?>
|
||||||
|
<div class="mokosuitefield-agreements">
|
||||||
|
<h2>MokoSuite Field — PM Agreements</h2>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
* @author Moko Consulting
|
||||||
|
*/
|
||||||
|
|
||||||
|
\defined('_JEXEC') or die;
|
||||||
|
?>
|
||||||
|
<div class="mokosuitefield-checklists">
|
||||||
|
<h2>MokoSuite Field — Checklists</h2>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
* @author Moko Consulting
|
||||||
|
*/
|
||||||
|
|
||||||
|
\defined('_JEXEC') or die;
|
||||||
|
?>
|
||||||
|
<div class="mokosuitefield-dashboard">
|
||||||
|
<h2>MokoSuite Field — Dashboard</h2>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
* @author Moko Consulting
|
||||||
|
*/
|
||||||
|
|
||||||
|
\defined('_JEXEC') or die;
|
||||||
|
?>
|
||||||
|
<div class="mokosuitefield-dispatches">
|
||||||
|
<h2>MokoSuite Field — Dispatches</h2>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
* @author Moko Consulting
|
||||||
|
*/
|
||||||
|
|
||||||
|
\defined('_JEXEC') or die;
|
||||||
|
?>
|
||||||
|
<div class="mokosuitefield-equipment">
|
||||||
|
<h2>MokoSuite Field — Equipment</h2>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
* @author Moko Consulting
|
||||||
|
*/
|
||||||
|
|
||||||
|
\defined('_JEXEC') or die;
|
||||||
|
?>
|
||||||
|
<div class="mokosuitefield-parts">
|
||||||
|
<h2>MokoSuite Field — Parts</h2>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
* @author Moko Consulting
|
||||||
|
*/
|
||||||
|
|
||||||
|
\defined('_JEXEC') or die;
|
||||||
|
?>
|
||||||
|
<div class="mokosuitefield-technicians">
|
||||||
|
<h2>MokoSuite Field — Technicians</h2>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
* @author Moko Consulting
|
||||||
|
*/
|
||||||
|
|
||||||
|
\defined('_JEXEC') or die;
|
||||||
|
?>
|
||||||
|
<div class="mokosuitefield-workorders">
|
||||||
|
<h2>MokoSuite Field — Work Orders</h2>
|
||||||
|
</div>
|
||||||
@@ -2,10 +2,23 @@
|
|||||||
<access component="com_mokosuitefield">
|
<access component="com_mokosuitefield">
|
||||||
<section name="component">
|
<section name="component">
|
||||||
<action name="core.admin" title="JACTION_ADMIN" />
|
<action name="core.admin" title="JACTION_ADMIN" />
|
||||||
|
<action name="core.options" title="JACTION_OPTIONS" />
|
||||||
<action name="core.manage" title="JACTION_MANAGE" />
|
<action name="core.manage" title="JACTION_MANAGE" />
|
||||||
<action name="core.create" title="JACTION_CREATE" />
|
<action name="core.create" title="JACTION_CREATE" />
|
||||||
|
<action name="core.delete" title="JACTION_DELETE" />
|
||||||
<action name="core.edit" title="JACTION_EDIT" />
|
<action name="core.edit" title="JACTION_EDIT" />
|
||||||
<action name="field.dispatch" title="Dispatch Work Orders" />
|
<action name="field.workorders.manage" title="Manage Work Orders" />
|
||||||
<action name="field.estimates" title="Create Estimates" />
|
<action name="field.workorders.dispatch" title="Dispatch Work Orders" />
|
||||||
|
<action name="field.workorders.complete" title="Complete Work Orders" />
|
||||||
|
<action name="field.workorders.invoice" title="Invoice Work Orders" />
|
||||||
|
<action name="field.equipment.manage" title="Manage Equipment" />
|
||||||
|
<action name="field.technicians.manage" title="Manage Technicians" />
|
||||||
|
<action name="field.parts.manage" title="Manage Parts" />
|
||||||
|
<action name="field.truckinventory.manage" title="Manage Truck Inventory" />
|
||||||
|
<action name="field.checklists.manage" title="Manage Checklists" />
|
||||||
|
<action name="field.pmagreements.manage" title="Manage PM Agreements" />
|
||||||
|
<action name="field.dispatch.view" title="View Dispatch Board" />
|
||||||
|
<action name="field.reports" title="View Reports" />
|
||||||
|
<action name="field.settings" title="Manage Settings" />
|
||||||
</section>
|
</section>
|
||||||
</access>
|
</access>
|
||||||
|
|||||||
@@ -1,25 +1,36 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<config>
|
<config>
|
||||||
<fieldset name="basic" label="Field Service Settings">
|
<fieldset name="basic" label="Company Defaults">
|
||||||
<field name="company_name" type="text" default="" label="Company Name" />
|
<field name="company_name" type="text" default="" label="Company Name" />
|
||||||
<field name="default_trade" type="list" default="general" label="Default Trade">
|
<field name="default_currency" type="text" default="USD" label="Default Currency" />
|
||||||
<option value="general">General</option>
|
<field name="timezone" type="text" default="UTC" label="Operating Timezone" />
|
||||||
<option value="plumbing">Plumbing</option>
|
<field name="distance_unit" type="list" default="mi" label="Distance Unit">
|
||||||
<option value="electrical">Electrical</option>
|
<option value="mi">Miles</option>
|
||||||
<option value="hvac">HVAC</option>
|
<option value="km">Kilometres</option>
|
||||||
</field>
|
</field>
|
||||||
<field name="wo_prefix" type="text" default="WO" label="Work Order Prefix" />
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset name="dispatch" label="Dispatch">
|
<fieldset name="dispatch" label="Dispatch">
|
||||||
<field name="auto_dispatch" type="radio" default="0" label="Auto-Dispatch" class="btn-group btn-group-yesno"><option value="1">JYES</option><option value="0">JNO</option></field>
|
<field name="max_dispatch_radius" type="number" default="50" label="Max Dispatch Radius" hint="In distance units" />
|
||||||
<field name="default_service_radius" type="number" default="30" label="Default Service Radius (miles)" />
|
<field name="auto_assign" type="radio" default="0" label="Auto-Assign Technician" class="btn-group btn-group-yesno">
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field name="priority_routing" type="radio" default="1" label="Priority-Based Routing" class="btn-group btn-group-yesno">
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset name="billing" label="Billing">
|
<fieldset name="billing" label="Billing">
|
||||||
<field name="default_labor_rate" type="number" default="125" step="0.01" label="Default Labor Rate ($/hr)" />
|
<field name="default_labor_rate" type="number" default="85.00" step="0.01" label="Default Labor Rate ($/hr)" />
|
||||||
<field name="overtime_multiplier" type="number" default="1.5" step="0.1" label="Overtime Multiplier" />
|
<field name="markup_percentage" type="number" default="30" step="1" label="Parts Markup (%)" />
|
||||||
<field name="travel_charge" type="number" default="0" step="0.01" label="Travel Charge ($)" />
|
<field name="tax_rate" type="number" default="0" step="0.01" label="Tax Rate (%)" />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset name="permissions" label="Permissions">
|
<fieldset name="scheduling" label="Scheduling">
|
||||||
<field name="rules" type="rules" component="com_mokosuitefield" section="component" />
|
<field name="business_hours_start" type="text" default="08:00" label="Business Hours Start" />
|
||||||
|
<field name="business_hours_end" type="text" default="17:00" label="Business Hours End" />
|
||||||
|
<field name="weekend_service" type="radio" default="0" label="Weekend Service" class="btn-group btn-group-yesno">
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</config>
|
</config>
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
* Authored-by: Moko Consulting
|
||||||
|
*/
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
|
|
||||||
use Joomla\CMS\Extension\ComponentInterface;
|
use Joomla\CMS\Extension\ComponentInterface;
|
||||||
use Joomla\CMS\Extension\MVCComponent;
|
use Joomla\CMS\Extension\MVCComponent;
|
||||||
|
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
|
||||||
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
|
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
|
||||||
use Joomla\DI\Container;
|
use Joomla\DI\Container;
|
||||||
use Joomla\DI\ServiceProviderInterface;
|
use Joomla\DI\ServiceProviderInterface;
|
||||||
|
|
||||||
return new class implements 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) {
|
$container->set(ComponentInterface::class, function (Container $container) {
|
||||||
$component = new MVCComponent($container->get(ComponentDispatcherFactoryInterface::class));
|
$c = new MVCComponent($container->get(ComponentDispatcherFactoryInterface::class));
|
||||||
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
|
$c->setMVCFactory($container->get(MVCFactoryInterface::class));
|
||||||
return $component;
|
return $c;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
* Authored-by: Moko Consulting
|
||||||
|
*/
|
||||||
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\Controller;
|
namespace Moko\Component\MokoSuiteField\Administrator\Controller;
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
@@ -7,5 +14,5 @@ use Joomla\CMS\MVC\Controller\BaseController;
|
|||||||
|
|
||||||
class DisplayController extends BaseController
|
class DisplayController extends BaseController
|
||||||
{
|
{
|
||||||
protected $default_view = 'dashboard';
|
protected $default_view = 'fielddashboard';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\Model;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
|
||||||
|
|
||||||
class DispatchModel extends BaseDatabaseModel
|
|
||||||
{
|
|
||||||
public function getTodayBoard(): array
|
|
||||||
{
|
|
||||||
$db = $this->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() ?: [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\Model;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
|
||||||
|
|
||||||
class EquipmentModel extends BaseDatabaseModel
|
|
||||||
{
|
|
||||||
public function getItems(string $type = '', string $status = '', int $locationId = 0, string $search = '', int $limit = 50): array
|
|
||||||
{
|
|
||||||
$db = $this->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() ?: [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\Model;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
|
||||||
|
|
||||||
class EstimatesModel extends BaseDatabaseModel
|
|
||||||
{
|
|
||||||
public function getItems(string $status = '', string $search = '', int $techId = 0, int $limit = 50): array
|
|
||||||
{
|
|
||||||
$db = $this->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() ?: [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\Model;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
|
||||||
|
|
||||||
class ServiceAgreementsModel extends BaseDatabaseModel
|
|
||||||
{
|
|
||||||
public function getItems(string $status = '', string $search = '', int $limit = 50): array
|
|
||||||
{
|
|
||||||
$db = $this->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() ?: [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\Model;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
|
||||||
|
|
||||||
class TechniciansModel extends BaseDatabaseModel
|
|
||||||
{
|
|
||||||
public function getItems(string $status = '', string $trade = '', string $search = '', int $limit = 50): array
|
|
||||||
{
|
|
||||||
$db = $this->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() ?: [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\Model;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
|
||||||
|
|
||||||
class VehiclesModel extends BaseDatabaseModel
|
|
||||||
{
|
|
||||||
public function getItems(string $status = '', int $techId = 0, int $limit = 50): array
|
|
||||||
{
|
|
||||||
$db = $this->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() ?: [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\Model;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
|
||||||
|
|
||||||
class WorkOrdersModel extends BaseDatabaseModel
|
|
||||||
{
|
|
||||||
public function getItems(string $status = '', string $trade = '', int $techId = 0, string $search = '', string $date = '', int $limit = 50, int $offset = 0): array
|
|
||||||
{
|
|
||||||
$db = $this->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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\View\Dashboard;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
|
||||||
{
|
|
||||||
public object $stats;
|
|
||||||
public array $dispatchBoard = [];
|
|
||||||
public array $unassigned = [];
|
|
||||||
public array $urgent = [];
|
|
||||||
|
|
||||||
public function display($tpl = null): void
|
|
||||||
{
|
|
||||||
$this->stats = \Moko\Plugin\System\MokoSuiteField\Helper\WorkOrderHelper::getDashboardStats();
|
|
||||||
$this->dispatchBoard = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getDispatchBoard();
|
|
||||||
$this->unassigned = \Moko\Plugin\System\MokoSuiteField\Helper\DispatchHelper::getUnassigned();
|
|
||||||
|
|
||||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
|
||||||
|
|
||||||
// Urgent/emergency jobs
|
|
||||||
$db->setQuery($db->getQuery(true)
|
|
||||||
->select('wo.*, cd.name AS customer_name, loc.address')
|
|
||||||
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
|
||||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
|
|
||||||
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
|
|
||||||
->where($db->quoteName('wo.priority') . ' IN (' . $db->quote('emergency') . ',' . $db->quote('urgent') . ')')
|
|
||||||
->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('completed') . ',' . $db->quote('cancelled') . ',' . $db->quote('invoiced') . ')')
|
|
||||||
->order('FIELD(wo.priority,' . $db->quote('emergency') . ',' . $db->quote('urgent') . ') ASC, wo.created ASC'), 0, 10);
|
|
||||||
$this->urgent = $db->loadObjectList() ?: [];
|
|
||||||
|
|
||||||
ToolbarHelper::title('MokoSuite Field Service', 'icon-wrench');
|
|
||||||
parent::display($tpl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\View\Dispatch;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
|
||||||
{
|
|
||||||
public array $board = [];
|
|
||||||
public array $unassigned = [];
|
|
||||||
public object $stats;
|
|
||||||
public string $date;
|
|
||||||
|
|
||||||
public function display($tpl = null): void
|
|
||||||
{
|
|
||||||
$this->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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
* Authored-by: Moko Consulting
|
||||||
|
*/
|
||||||
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\View\Equipment;
|
namespace Moko\Component\MokoSuiteField\Administrator\View\Equipment;
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
class HtmlView extends BaseHtmlView
|
||||||
{
|
{
|
||||||
public array $equipment = [];
|
|
||||||
public array $serviceDue = [];
|
|
||||||
|
|
||||||
public function display($tpl = null): void
|
public function display($tpl = null): void
|
||||||
{
|
{
|
||||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
ToolbarHelper::title('MokoSuite Field - Equipment');
|
||||||
|
|
||||||
$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);
|
parent::display($tpl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\View\ServiceAgreements;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
|
||||||
{
|
|
||||||
public array $agreements = [];
|
|
||||||
public object $revenue;
|
|
||||||
|
|
||||||
public function display($tpl = null): void
|
|
||||||
{
|
|
||||||
$this->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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
* Authored-by: Moko Consulting
|
||||||
|
*/
|
||||||
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\View\Technicians;
|
namespace Moko\Component\MokoSuiteField\Administrator\View\Technicians;
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
class HtmlView extends BaseHtmlView
|
||||||
{
|
{
|
||||||
public array $technicians = [];
|
|
||||||
|
|
||||||
public function display($tpl = null): void
|
public function display($tpl = null): void
|
||||||
{
|
{
|
||||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
ToolbarHelper::title('MokoSuite Field - Technicians');
|
||||||
|
|
||||||
$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);
|
parent::display($tpl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\View\Vehicles;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
|
||||||
{
|
|
||||||
public array $vehicles = [];
|
|
||||||
public array $inspectionsDue = [];
|
|
||||||
|
|
||||||
public function display($tpl = null): void
|
|
||||||
{
|
|
||||||
$this->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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
* Authored-by: Moko Consulting
|
||||||
|
*/
|
||||||
|
|
||||||
namespace Moko\Component\MokoSuiteField\Administrator\View\WorkOrders;
|
namespace Moko\Component\MokoSuiteField\Administrator\View\WorkOrders;
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
class HtmlView extends BaseHtmlView
|
||||||
{
|
{
|
||||||
public array $orders = [];
|
|
||||||
public array $filters = [];
|
|
||||||
|
|
||||||
public function display($tpl = null): void
|
public function display($tpl = null): void
|
||||||
{
|
{
|
||||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
ToolbarHelper::title('MokoSuite Field - Work Orders');
|
||||||
$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);
|
parent::display($tpl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<?php defined('_JEXEC') or die; ?><div><h2>Checklists</h2><p>Coming soon.</p></div>
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
$s = $this->stats;
|
|
||||||
$board = $this->dispatchBoard;
|
|
||||||
$unassigned = $this->unassigned;
|
|
||||||
$urgent = $this->urgent;
|
|
||||||
?>
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int) $s->total_today; ?></div><small>Today</small></div></div></div>
|
|
||||||
<div class="col-md-2"><div class="card shadow-sm border-danger"><div class="card-body text-center"><div class="fs-3 fw-bold text-danger"><?php echo (int) $s->urgent; ?></div><small>Urgent</small></div></div></div>
|
|
||||||
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-secondary"><?php echo (int) $s->unassigned; ?></div><small>Unassigned</small></div></div></div>
|
|
||||||
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-warning"><?php echo (int) $s->en_route; ?></div><small>En Route</small></div></div></div>
|
|
||||||
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-primary"><?php echo (int) $s->on_site; ?></div><small>On Site</small></div></div></div>
|
|
||||||
<div class="col-md-2"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-success"><?php echo (int) $s->completed; ?></div><small>Done</small></div></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-lg-8"><div class="card shadow-sm"><div class="card-header"><h5 class="mb-0">Dispatch Board</h5></div><div class="card-body">
|
|
||||||
<?php foreach ($board as $tech) : ?>
|
|
||||||
<div class="mb-3 p-2 border rounded">
|
|
||||||
<div class="d-flex justify-content-between mb-1"><strong><?php echo $this->escape($tech->tech_name); ?></strong><span class="badge bg-secondary"><?php echo ucfirst($tech->trade); ?></span></div>
|
|
||||||
<?php if (!empty($tech->jobs)) : foreach ($tech->jobs as $job) : ?>
|
|
||||||
<div class="ms-3 small border-start ps-2 mb-1"><code><?php echo $this->escape($job->wo_number); ?></code> <?php echo $this->escape($job->customer_name ?? ''); ?> <span class="text-muted"><?php echo $this->escape($job->city ?? ''); ?></span></div>
|
|
||||||
<?php endforeach; else : ?><div class="ms-3 small text-muted">No jobs</div><?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div></div></div>
|
|
||||||
<div class="col-lg-4"><div class="card shadow-sm"><div class="card-header"><h5 class="mb-0">Unassigned (<?php echo count($unassigned); ?>)</h5></div><div class="card-body p-0">
|
|
||||||
<?php foreach ($unassigned as $u) : ?>
|
|
||||||
<div class="p-2 border-bottom"><strong class="small"><?php echo $this->escape($u->customer_name ?? ''); ?></strong><br><small class="text-muted"><?php echo $this->escape($u->category ?? $u->trade); ?></small></div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php if (empty($unassigned)) : ?><div class="p-3 text-muted text-center">All assigned</div><?php endif; ?>
|
|
||||||
</div></div></div>
|
|
||||||
</div>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
$board=$this->board;$s=$this->stats;
|
|
||||||
?>
|
|
||||||
<div class="row g-3 mb-4"><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int)$s->total_today; ?></div><small>Today</small></div></div></div><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-danger"><?php echo (int)$s->urgent; ?></div><small>Urgent</small></div></div></div><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-warning"><?php echo (int)$s->en_route; ?></div><small>En Route</small></div></div></div><div class="col-md-3"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-success"><?php echo (int)$s->completed; ?></div><small>Done</small></div></div></div></div>
|
|
||||||
<?php foreach($board as $tech): ?>
|
|
||||||
<div class="card shadow-sm mb-2"><div class="card-body p-2"><strong><?php echo $this->escape($tech->tech_name); ?></strong>
|
|
||||||
<?php foreach($tech->jobs as $job): ?><div class="ms-3 small"><?php echo $this->escape($job->wo_number); ?> <?php echo $this->escape($job->customer_name); ?></div><?php endforeach; ?>
|
|
||||||
</div></div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<?php defined('_JEXEC') or die; ?><div><h2>Dispatches</h2><p>Coming soon.</p></div>
|
||||||
@@ -1,10 +1 @@
|
|||||||
<?php
|
<?php defined('_JEXEC') or die; ?><div><h2>Equipment</h2><p>Coming soon.</p></div>
|
||||||
defined('_JEXEC') or die;
|
|
||||||
$equip=$this->equipment;$due=$this->serviceDue;
|
|
||||||
?>
|
|
||||||
<?php if(!empty($due)): ?><div class="alert alert-warning"><?php echo count($due); ?> equipment due</div><?php endif; ?>
|
|
||||||
<table class="table table-striped"><thead><tr><th>Type</th><th>Make/Model</th><th>Serial</th><th>Owner</th><th>Last Service</th></tr></thead><tbody>
|
|
||||||
<?php foreach($equip as $e): ?>
|
|
||||||
<tr><td><?php echo ucfirst(str_replace("_"," ",$e->equipment_type)); ?></td><td><?php echo $this->escape($e->make." ".$e->model); ?></td><td><code><?php echo $this->escape($e->serial_number); ?></code></td><td><?php echo $this->escape($e->owner_name); ?></td><td><?php echo $e->last_service_date?date("M j",strtotime($e->last_service_date)):"Never"; ?></td></tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody></table>
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<?php defined('_JEXEC') or die; ?><div><h2>Dashboard</h2><p>Coming soon.</p></div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<?php defined('_JEXEC') or die; ?><div><h2>Parts</h2><p>Coming soon.</p></div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<?php defined('_JEXEC') or die; ?><div><h2>PM Agreements</h2><p>Coming soon.</p></div>
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?php defined('_JEXEC') or die; $agreements=$this->agreements; $rev=$this->revenue; ?>
|
|
||||||
<div class="row g-3 mb-4"><div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold"><?php echo (int)$rev->active_agreements; ?></div><small>Active Agreements</small></div></div></div><div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold text-success">$<?php echo number_format((float)$rev->annual_recurring,0); ?></div><small>Annual Recurring</small></div></div></div><div class="col-md-4"><div class="card shadow-sm"><div class="card-body text-center"><div class="fs-3 fw-bold">$<?php echo number_format((float)$rev->monthly_recurring,0); ?></div><small>Monthly</small></div></div></div></div>
|
|
||||||
<table class="table table-striped"><thead class="table-light"><tr><th>Agreement</th><th>Customer</th><th>Trade</th><th>Visits</th><th>Annual</th><th>Status</th><th>Expires</th></tr></thead><tbody>
|
|
||||||
<?php foreach($agreements as $a): ?>
|
|
||||||
<tr><td><strong><?php echo htmlspecialchars($a->title); ?></strong></td><td><?php echo htmlspecialchars($a->customer_name??""); ?></td><td><?php echo ucfirst($a->trade); ?></td><td><?php echo $a->visits_remaining; ?> of <?php echo (int)$a->visits_per_year; ?> left</td><td>$<?php echo number_format((float)$a->annual_amount,0); ?></td><td><span class="badge bg-<?php echo $a->status==="active"?"success":"warning"; ?>"><?php echo ucfirst($a->status); ?></span></td><td><?php echo $a->end_date?date("M j, Y",strtotime($a->end_date)):"—"; ?></td></tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php if(empty($agreements)): ?><tr><td colspan="7" class="text-muted text-center py-4">No agreements</td></tr><?php endif; ?>
|
|
||||||
</tbody></table>
|
|
||||||
@@ -1,7 +1 @@
|
|||||||
<?php defined('_JEXEC') or die; $techs=$this->technicians; $statusColors=["available"=>"success","dispatched"=>"info","en_route"=>"warning","on_site"=>"primary","off_duty"=>"secondary","on_leave"=>"dark"]; ?>
|
<?php defined('_JEXEC') or die; ?><div><h2>Technicians</h2><p>Coming soon.</p></div>
|
||||||
<table class="table table-striped table-hover"><thead class="table-light"><tr><th>Tech</th><th>Trade</th><th>Status</th><th>Phone</th><th>Vehicle</th><th>License</th><th>Jobs/Month</th></tr></thead><tbody>
|
|
||||||
<?php foreach($techs as $t): ?>
|
|
||||||
<tr><td><strong><?php echo htmlspecialchars($t->tech_name??""); ?></strong></td><td><?php echo ucfirst($t->trade); ?></td><td><span class="badge bg-<?php echo $statusColors[$t->status]??"secondary"; ?>"><?php echo ucfirst(str_replace("_"," ",$t->status)); ?></span></td><td class="small"><?php echo htmlspecialchars($t->telephone??""); ?></td><td><?php echo htmlspecialchars($t->vehicle_number??"—"); ?></td><td class="small"><?php echo htmlspecialchars($t->license_number??"—"); ?></td><td><?php echo (int)($t->jobs_this_month??0); ?></td></tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php if(empty($techs)): ?><tr><td colspan="7" class="text-muted text-center py-4">No technicians</td></tr><?php endif; ?>
|
|
||||||
</tbody></table>
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<?php defined('_JEXEC') or die; ?><div><h2>Truck Inventory</h2><p>Coming soon.</p></div>
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
$vehicles=$this->vehicles;
|
|
||||||
?>
|
|
||||||
<table class="table table-striped"><thead><tr><th>Vehicle</th><th>Make/Model</th><th>Assigned To</th><th>Mileage</th><th>Status</th></tr></thead><tbody>
|
|
||||||
<?php foreach($vehicles as $v): ?>
|
|
||||||
<tr><td><strong><?php echo $this->escape($v->vehicle_number); ?></strong></td><td><?php echo $this->escape($v->make." ".$v->model); ?></td><td><?php echo $this->escape($v->assigned_tech_name); ?></td><td><?php echo $v->mileage?number_format((int)$v->mileage):"—"; ?></td><td><?php echo ucfirst($v->status); ?></td></tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody></table>
|
|
||||||
@@ -1,34 +1 @@
|
|||||||
<?php
|
<?php defined('_JEXEC') or die; ?><div><h2>Work Orders</h2><p>Coming soon.</p></div>
|
||||||
defined('_JEXEC') or die;
|
|
||||||
use Joomla\CMS\Router\Route;
|
|
||||||
$orders = $this->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'];
|
|
||||||
?>
|
|
||||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitefield&view=workorders'); ?>" method="post" name="adminForm" id="adminForm">
|
|
||||||
<div class="row g-2 mb-3">
|
|
||||||
<div class="col-auto"><select name="filter_status" class="form-select form-select-sm" onchange="this.form.submit()"><option value="">All Statuses</option>
|
|
||||||
<?php foreach($statusColors as $k=>$v): ?><option value="<?php echo $k; ?>" <?php echo $f['status']===$k?'selected':''; ?>><?php echo ucfirst(str_replace('_',' ',$k)); ?></option><?php endforeach; ?>
|
|
||||||
</select></div>
|
|
||||||
<div class="col-auto"><input type="date" name="filter_date" class="form-control form-control-sm" value="<?php echo $this->escape($f['date']); ?>" onchange="this.form.submit()" /></div>
|
|
||||||
<div class="col-auto"><input type="text" name="filter_search" class="form-control form-control-sm" placeholder="Search..." value="<?php echo $this->escape($f['search']); ?>" /></div>
|
|
||||||
<div class="col-auto"><button type="submit" class="btn btn-sm btn-outline-primary">Filter</button></div>
|
|
||||||
</div>
|
|
||||||
<table class="table table-striped table-hover">
|
|
||||||
<thead class="table-light"><tr><th>WO#</th><th>Customer</th><th>Trade</th><th>Priority</th><th>Status</th><th>Technician</th><th>Scheduled</th><th>Total</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach($orders as $wo): ?>
|
|
||||||
<tr><td><code><?php echo $this->escape($wo->wo_number); ?></code></td>
|
|
||||||
<td><?php echo $this->escape($wo->customer_name??''); ?><br><small class="text-muted"><?php echo $this->escape($wo->city??''); ?></small></td>
|
|
||||||
<td><?php echo ucfirst($wo->trade); ?></td>
|
|
||||||
<td><span class="badge bg-<?php echo $priorityColors[$wo->priority]??'secondary'; ?>"><?php echo ucfirst($wo->priority); ?></span></td>
|
|
||||||
<td><span class="badge bg-<?php echo $statusColors[$wo->status]??'secondary'; ?>"><?php echo ucfirst(str_replace('_',' ',$wo->status)); ?></span></td>
|
|
||||||
<td><?php echo $this->escape($wo->tech_name??'Unassigned'); ?></td>
|
|
||||||
<td><?php echo $wo->scheduled_date?date('M j',strtotime($wo->scheduled_date)):'—'; ?></td>
|
|
||||||
<td><?php echo (float)$wo->total>0?'$'.number_format((float)$wo->total,2):'—'; ?></td></tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php if(empty($orders)): ?><tr><td colspan="8" class="text-muted text-center py-4">No work orders</td></tr><?php endif; ?>
|
|
||||||
</tbody></table>
|
|
||||||
<input type="hidden" name="task" value="" /><?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
|
|
||||||
</form>
|
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Api\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\Controller\BaseController;
|
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Equipment + Vehicles + Service Agreements API.
|
|
||||||
*
|
|
||||||
* GET /equipment — List equipment
|
|
||||||
* GET /equipment/{id} — Equipment detail with service history
|
|
||||||
* GET /vehicles — List vehicles (fleet)
|
|
||||||
* GET /vehicles/{id}/stock — Truck stock for a vehicle
|
|
||||||
* GET /agreements — List service agreements
|
|
||||||
* GET /agreements/{id} — Agreement detail with WO history
|
|
||||||
*/
|
|
||||||
class FieldEquipmentController extends BaseController
|
|
||||||
{
|
|
||||||
private function requireAuth(string $action = 'core.manage'): void
|
|
||||||
{
|
|
||||||
$user = Factory::getApplication()->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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Api\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\Controller\BaseController;
|
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimates + Route API.
|
|
||||||
*
|
|
||||||
* GET /estimates — List estimates
|
|
||||||
* POST /estimates — Create estimate from field
|
|
||||||
* PATCH /estimates/{id}/status — Update estimate status (approve/reject)
|
|
||||||
* POST /estimates/{id}/convert— Convert estimate to work order
|
|
||||||
* GET /route/{techId} — Get optimized daily route
|
|
||||||
* POST /route/{techId}/optimize — Trigger route optimization
|
|
||||||
*/
|
|
||||||
class FieldEstimatesController extends BaseController
|
|
||||||
{
|
|
||||||
private function requireAuth(string $action = 'core.manage'): void
|
|
||||||
{
|
|
||||||
$user = Factory::getApplication()->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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Api\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\Controller\BaseController;
|
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mobile tech API — GPS tracking, photo upload, time tracking, parts usage.
|
|
||||||
* Designed for offline-capable mobile apps (queue + sync).
|
|
||||||
*
|
|
||||||
* GET /mobile/jobs — Today's jobs for authenticated tech
|
|
||||||
* POST /mobile/status — Update WO status with GPS
|
|
||||||
* POST /mobile/photo — Upload work order photo
|
|
||||||
* POST /mobile/time/start — Start time entry
|
|
||||||
* POST /mobile/time/stop — Stop time entry
|
|
||||||
* POST /mobile/part — Log part usage from truck stock
|
|
||||||
* POST /mobile/location — GPS heartbeat
|
|
||||||
* GET /mobile/equipment/{qr} — QR code equipment lookup
|
|
||||||
*/
|
|
||||||
class FieldMobileController extends BaseController
|
|
||||||
{
|
|
||||||
private function requireTech(): object
|
|
||||||
{
|
|
||||||
$user = Factory::getApplication()->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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Api\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\Controller\BaseController;
|
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Field Service Reports API.
|
|
||||||
*
|
|
||||||
* GET /reports/tech-performance — Technician performance metrics
|
|
||||||
* GET /reports/revenue — Revenue by trade/period
|
|
||||||
* GET /reports/parts-usage — Parts consumption summary
|
|
||||||
* GET /reports/sla-compliance — SLA compliance rates
|
|
||||||
*/
|
|
||||||
class FieldReportsController extends BaseController
|
|
||||||
{
|
|
||||||
private function requireAuth(): void
|
|
||||||
{
|
|
||||||
$user = Factory::getApplication()->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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Api\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\Controller\BaseController;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scheduling + Daily Route API.
|
|
||||||
*
|
|
||||||
* GET /schedule/slots — Available appointment slots
|
|
||||||
* POST /schedule/book — Schedule a work order
|
|
||||||
* GET /schedule/today — Today's schedule for all techs
|
|
||||||
* GET /schedule/tech/{id} — Single tech's daily route
|
|
||||||
*/
|
|
||||||
class FieldSchedulingController extends BaseController
|
|
||||||
{
|
|
||||||
private function requireAuth(string $action = 'core.manage'): void
|
|
||||||
{
|
|
||||||
$user = Factory::getApplication()->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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoSuiteField\Api\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\Controller\BaseController;
|
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Work Order + Dispatch API.
|
|
||||||
*
|
|
||||||
* GET /workorders — List work orders
|
|
||||||
* POST /workorders — Create work order
|
|
||||||
* PATCH /workorders/{id}/status — Update status (with GPS)
|
|
||||||
* POST /workorders/{id}/dispatch — Dispatch to technician
|
|
||||||
* GET /dispatch/board — Today's dispatch board
|
|
||||||
* GET /technicians/available — Available techs by trade
|
|
||||||
*/
|
|
||||||
class FieldWorkOrderController extends BaseController
|
|
||||||
{
|
|
||||||
private function requireAuth(string $action = 'core.manage'): void
|
|
||||||
{
|
|
||||||
$user = Factory::getApplication()->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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/* 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; } }
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Auto-refresh dispatch board every 30 seconds
|
|
||||||
if (document.querySelector('.dispatch-board')) {
|
|
||||||
setInterval(function() { location.reload(); }, 30000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<extension type="component" method="upgrade">
|
||||||
|
<name>MokoSuite Field</name>
|
||||||
|
<author>Moko Consulting</author>
|
||||||
|
<creationDate>2026-06-27</creationDate>
|
||||||
|
<copyright>Copyright (C) 2026 Moko Consulting.</copyright>
|
||||||
|
<license>GPL-3.0-or-later</license>
|
||||||
|
<version>06.00.09</version>
|
||||||
|
<namespace path="src">Moko\Component\MokoSuiteField</namespace>
|
||||||
|
<administration>
|
||||||
|
<files folder="admin"><folder>services</folder><folder>src</folder><folder>tmpl</folder></files>
|
||||||
|
<menu>COM_MOKOSUITEFIELD</menu>
|
||||||
|
</administration>
|
||||||
|
</extension>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user