Compare commits
68 Commits
react_test
...
d86a60c609
| Author | SHA1 | Date | |
|---|---|---|---|
| d86a60c609 | |||
| 998b85e907 | |||
| a3298e7dbe | |||
| 08a73eb84c | |||
| 837c4baf56 | |||
| becee84821 | |||
| 4a05c82852 | |||
| e801eee6ee | |||
| 58f2e9d143 | |||
| 783bf52552 | |||
| 8137a007a1 | |||
| f46b1c31b5 | |||
| fa9d9dfe0f | |||
| f096766e5d | |||
| 962724d811 | |||
| f9d9c775fb | |||
| 2633d0f962 | |||
| 2e7956ce40 | |||
| 61e3bd69ff | |||
| 83a1ba393a | |||
| b781a44ed5 | |||
| eda9c61724 | |||
| 1a74536079 | |||
| 649854c86e | |||
| 0b304ace95 | |||
| 590a24bc99 | |||
| d27b61c56d | |||
| 3be5b24d1f | |||
| 1bd329ac1b | |||
| d6dae6825c | |||
| d76ff7aad0 | |||
| de9da756e9 | |||
| 80db9d76f8 | |||
| 6a4d74c5ea | |||
| cf2d596219 | |||
| 0f25c49d5c | |||
| 719905872b | |||
| e0b7ce374d | |||
| c449100c28 | |||
| aff2b92bea | |||
| 8aa5c39ed8 | |||
| eda40a150b | |||
| 46468eef99 | |||
| 57d572a15e | |||
| c95f1db871 | |||
| 2086730c9b | |||
| 47775e19c7 | |||
| ac094c8706 | |||
| 353ce83a36 | |||
| 683420cdb9 | |||
| 99e60795c9 | |||
| f9629f60b6 | |||
| 9a12a656aa | |||
| 2b7ece4eec | |||
| 74da7bf819 | |||
| de4911a8b4 | |||
| b29ee6653b | |||
| bfac062dd3 | |||
| 3abaf3d13f | |||
| b8a0fb011f | |||
| 7b25ec1dd1 | |||
| 661643e45b | |||
| b241f7b0da | |||
| 3eccb5ce2c | |||
| d4fb7ea2ed | |||
| 52d4ac1d60 | |||
| 5b360ac7d2 | |||
| d7a5ab3576 |
727
Claude.md
Normal file
727
Claude.md
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
# Tinker Tickets - Project Documentation for AI Assistants
|
||||||
|
|
||||||
|
## Project Status (January 2026)
|
||||||
|
|
||||||
|
**Current Phase**: All 5 core features implemented and deployed. Ready for ANSI Art redesign.
|
||||||
|
|
||||||
|
**Recent Completion**:
|
||||||
|
- ✅ Activity Timeline (Feature 1)
|
||||||
|
- ✅ Ticket Assignment (Feature 2)
|
||||||
|
- ✅ Status Transitions with Workflows (Feature 3)
|
||||||
|
- ✅ Ticket Templates (Feature 4)
|
||||||
|
- ✅ Bulk Actions - Admin Only (Feature 5)
|
||||||
|
|
||||||
|
**Next Priority**: 🎨 ANSI Art Redesign (major visual overhaul)
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Tinker Tickets is a feature-rich, self-hosted ticket management system built for managing data center infrastructure issues. It features SSO integration with Authelia/LLDAP, workflow management, Discord notifications, and a comprehensive web interface.
|
||||||
|
|
||||||
|
**Tech Stack:**
|
||||||
|
- Backend: PHP 7.4+ with MySQLi
|
||||||
|
- Frontend: Vanilla JavaScript, CSS3
|
||||||
|
- Database: MariaDB on separate LXC (10.10.10.50)
|
||||||
|
- Web Server: Apache on production (10.10.10.45)
|
||||||
|
- Authentication: Authelia SSO with LLDAP backend
|
||||||
|
- External Libraries: marked.js (Markdown rendering)
|
||||||
|
|
||||||
|
**Production Environment:**
|
||||||
|
- **Primary URL**: http://t.lotusguild.org
|
||||||
|
- **Web Server**: Apache at 10.10.10.45 (`/root/code/tinker_tickets`)
|
||||||
|
- **Database**: MariaDB at 10.10.10.50 (`ticketing_system` database)
|
||||||
|
- **Authentication**: Authelia provides SSO via headers
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### MVC Pattern
|
||||||
|
```
|
||||||
|
Controllers → Models → Database
|
||||||
|
↓
|
||||||
|
Views
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure (Updated)
|
||||||
|
```
|
||||||
|
/tinker_tickets/
|
||||||
|
├── api/ # API endpoints
|
||||||
|
│ ├── add_comment.php # POST: Add comment
|
||||||
|
│ ├── assign_ticket.php # POST: Assign ticket to user (NEW)
|
||||||
|
│ ├── bulk_operation.php # POST: Bulk operations - admin only (NEW)
|
||||||
|
│ ├── get_template.php # GET: Fetch ticket template (NEW)
|
||||||
|
│ ├── get_users.php # GET: Get user list (NEW)
|
||||||
|
│ └── update_ticket.php # POST: Update ticket (workflow validation)
|
||||||
|
├── assets/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ ├── dashboard.css # Shared + dashboard + bulk actions
|
||||||
|
│ │ └── ticket.css # Ticket + timeline + dark mode fixes
|
||||||
|
│ ├── js/
|
||||||
|
│ │ ├── dashboard.js # Dashboard + hamburger + bulk actions + templates
|
||||||
|
│ │ └── ticket.js # Ticket + comments + status updates + assignment
|
||||||
|
│ └── images/
|
||||||
|
│ └── favicon.png
|
||||||
|
├── config/
|
||||||
|
│ └── config.php # Config + .env loading
|
||||||
|
├── controllers/ # MVC Controllers
|
||||||
|
│ ├── DashboardController.php # Dashboard with assigned_to column
|
||||||
|
│ └── TicketController.php # Ticket CRUD + timeline + templates
|
||||||
|
├── models/ # Data models
|
||||||
|
│ ├── AuditLogModel.php # Audit logging + timeline
|
||||||
|
│ ├── BulkOperationsModel.php # Bulk operations tracking (NEW)
|
||||||
|
│ ├── CommentModel.php # Comment data access
|
||||||
|
│ ├── TemplateModel.php # Ticket templates (NEW)
|
||||||
|
│ ├── TicketModel.php # Ticket CRUD + assignment
|
||||||
|
│ ├── UserModel.php # User management (NEW)
|
||||||
|
│ └── WorkflowModel.php # Status transition workflows (NEW)
|
||||||
|
├── views/ # PHP templates
|
||||||
|
│ ├── CreateTicketView.php # Ticket creation with templates
|
||||||
|
│ ├── DashboardView.php # Dashboard with bulk actions + assigned column
|
||||||
|
│ └── TicketView.php # Ticket view with timeline + assignment
|
||||||
|
├── migrations/ # Database migrations
|
||||||
|
│ ├── 001_initial_schema.sql
|
||||||
|
│ ├── 007_add_ticket_assignment.sql # Ticket assignment
|
||||||
|
│ ├── 008_add_status_workflows.sql # Workflow rules
|
||||||
|
│ ├── 009_add_ticket_templates.sql # Ticket templates
|
||||||
|
│ ├── 010_add_bulk_operations.sql # Bulk operations
|
||||||
|
│ └── 011_remove_view_tracking.sql # Remove view audit logs
|
||||||
|
├── .env # Environment variables (GITIGNORED)
|
||||||
|
├── Claude.md # This file
|
||||||
|
├── README.md # User documentation
|
||||||
|
├── index.php # Dashboard entry point
|
||||||
|
└── ticket.php # Ticket view/create router
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema (Updated)
|
||||||
|
|
||||||
|
**Database**: `ticketing_system` at 10.10.10.50
|
||||||
|
**User**: `tinkertickets`
|
||||||
|
**Connection**: All APIs create their own connections via config.php
|
||||||
|
|
||||||
|
### Core Tables
|
||||||
|
|
||||||
|
#### `tickets` Table (Updated)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE tickets (
|
||||||
|
ticket_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status VARCHAR(50) DEFAULT 'Open',
|
||||||
|
priority INT DEFAULT 4,
|
||||||
|
category VARCHAR(50) DEFAULT 'General',
|
||||||
|
type VARCHAR(50) DEFAULT 'Issue',
|
||||||
|
created_by INT, -- User who created
|
||||||
|
updated_by INT, -- User who last updated
|
||||||
|
assigned_to INT, -- User assigned to (NEW)
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(user_id),
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES users(user_id),
|
||||||
|
FOREIGN KEY (assigned_to) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_assigned_to (assigned_to)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `users` Table (SSO Integration)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
user_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
email VARCHAR(255),
|
||||||
|
is_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
last_login TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `comments` Table
|
||||||
|
```sql
|
||||||
|
CREATE TABLE comments (
|
||||||
|
comment_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ticket_id INT NOT NULL,
|
||||||
|
user_id INT,
|
||||||
|
comment_text TEXT NOT NULL,
|
||||||
|
markdown_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id),
|
||||||
|
INDEX idx_ticket_id (ticket_id)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `audit_log` Table (Activity Timeline)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
log_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT,
|
||||||
|
action_type VARCHAR(50) NOT NULL, -- 'create', 'update', 'comment', 'assign', etc.
|
||||||
|
entity_type VARCHAR(50) NOT NULL, -- 'ticket', 'comment'
|
||||||
|
entity_id INT NOT NULL, -- ticket_id or comment_id
|
||||||
|
details JSON, -- JSON details of what changed
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id),
|
||||||
|
INDEX idx_entity (entity_type, entity_id),
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_action (action_type)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `status_transitions` Table (Workflow Rules)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE status_transitions (
|
||||||
|
transition_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
from_status VARCHAR(50) NOT NULL,
|
||||||
|
to_status VARCHAR(50) NOT NULL,
|
||||||
|
requires_comment BOOLEAN DEFAULT FALSE, -- Transition requires comment
|
||||||
|
requires_admin BOOLEAN DEFAULT FALSE, -- Transition requires admin
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY unique_transition (from_status, to_status),
|
||||||
|
INDEX idx_from_status (from_status)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
```
|
||||||
|
|
||||||
|
Default transitions:
|
||||||
|
```sql
|
||||||
|
-- Open → In Progress, Closed, Resolved
|
||||||
|
-- In Progress → Open, Closed, Resolved
|
||||||
|
-- Resolved → Closed, In Progress
|
||||||
|
-- Closed → Open, In Progress (requires comment)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `ticket_templates` Table
|
||||||
|
```sql
|
||||||
|
CREATE TABLE ticket_templates (
|
||||||
|
template_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
template_name VARCHAR(100) NOT NULL,
|
||||||
|
title_template VARCHAR(255) NOT NULL,
|
||||||
|
description_template TEXT NOT NULL,
|
||||||
|
category VARCHAR(50),
|
||||||
|
type VARCHAR(50),
|
||||||
|
default_priority INT DEFAULT 4,
|
||||||
|
created_by INT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(user_id),
|
||||||
|
INDEX idx_template_name (template_name)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
```
|
||||||
|
|
||||||
|
Default templates: Hardware Failure, Software Installation, Network Issue, Maintenance Request
|
||||||
|
|
||||||
|
#### `bulk_operations` Table
|
||||||
|
```sql
|
||||||
|
CREATE TABLE bulk_operations (
|
||||||
|
operation_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
operation_type VARCHAR(50) NOT NULL, -- 'bulk_close', 'bulk_assign', 'bulk_priority'
|
||||||
|
ticket_ids TEXT NOT NULL, -- Comma-separated ticket IDs
|
||||||
|
performed_by INT NOT NULL,
|
||||||
|
parameters JSON, -- Operation parameters
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
total_tickets INT,
|
||||||
|
processed_tickets INT DEFAULT 0,
|
||||||
|
failed_tickets INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP NULL,
|
||||||
|
FOREIGN KEY (performed_by) REFERENCES users(user_id),
|
||||||
|
INDEX idx_performed_by (performed_by),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints (Updated)
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
All API endpoints check: `$_SESSION['user']['user_id']` for authentication.
|
||||||
|
Admin-only endpoints check: `$_SESSION['user']['is_admin']`.
|
||||||
|
|
||||||
|
### POST `/api/update_ticket.php`
|
||||||
|
Updates ticket with workflow validation.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ticket_id": 123,
|
||||||
|
"status": "In Progress", // Validated against workflow rules
|
||||||
|
"priority": 2,
|
||||||
|
"title": "Updated title",
|
||||||
|
"description": "...",
|
||||||
|
"category": "Software",
|
||||||
|
"type": "Task"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"status": "In Progress",
|
||||||
|
"priority": 2,
|
||||||
|
"message": "Ticket updated successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Workflow validation via WorkflowModel
|
||||||
|
- Partial updates (only send changed fields)
|
||||||
|
- User tracking (updated_by)
|
||||||
|
- Discord webhook notifications
|
||||||
|
- Audit logging
|
||||||
|
|
||||||
|
### POST `/api/assign_ticket.php` (NEW)
|
||||||
|
Assigns ticket to a user.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ticket_id": 123,
|
||||||
|
"assigned_to": 5 // user_id, or null to unassign
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/api/get_users.php` (NEW)
|
||||||
|
Returns list of all users for assignment dropdowns.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"user_id": 1,
|
||||||
|
"username": "jared",
|
||||||
|
"display_name": "Jared Vititoe",
|
||||||
|
"is_admin": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/api/get_template.php?template_id=1` (NEW)
|
||||||
|
Fetches a ticket template.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"template": {
|
||||||
|
"template_id": 1,
|
||||||
|
"template_name": "Hardware Failure",
|
||||||
|
"title_template": "Hardware Failure: [Device Name]",
|
||||||
|
"description_template": "Device: \nIssue: \n...",
|
||||||
|
"category": "Hardware",
|
||||||
|
"type": "Problem",
|
||||||
|
"default_priority": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST `/api/bulk_operation.php` (NEW - ADMIN ONLY)
|
||||||
|
Performs bulk operations on tickets.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation_type": "bulk_close", // or 'bulk_assign', 'bulk_priority'
|
||||||
|
"ticket_ids": [123, 456, 789],
|
||||||
|
"parameters": { // For bulk_assign or bulk_priority
|
||||||
|
"assigned_to": 5, // For bulk_assign
|
||||||
|
"priority": 2 // For bulk_priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"operation_id": 42,
|
||||||
|
"processed": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"message": "Bulk operation completed: 3 succeeded, 0 failed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST `/api/add_comment.php`
|
||||||
|
Adds comment to ticket.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ticket_id": 123,
|
||||||
|
"comment_text": "Comment content",
|
||||||
|
"markdown_enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"user_name": "Jared Vititoe",
|
||||||
|
"created_at": "Jan 01, 2026 12:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Features Implementation
|
||||||
|
|
||||||
|
### Feature 1: Activity Timeline
|
||||||
|
**Location**: Ticket view → Activity tab
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
- `AuditLogModel->getTicketTimeline()` - Fetches all events for a ticket
|
||||||
|
- Shows: creates, updates, comments, assignments, status changes
|
||||||
|
- Displays: user, action, timestamp, details
|
||||||
|
- CSS: timeline-content boxes with icons
|
||||||
|
- Dark mode: Fully supported
|
||||||
|
|
||||||
|
**Code**: `views/TicketView.php:258-282`, `models/AuditLogModel.php:getTicketTimeline()`
|
||||||
|
|
||||||
|
### Feature 2: Ticket Assignment
|
||||||
|
**Location**: Ticket view → "Assigned to" dropdown, Dashboard → "Assigned To" column
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
- Database: `tickets.assigned_to` column
|
||||||
|
- Models: `TicketModel->assignTicket()`, `TicketModel->unassignTicket()`
|
||||||
|
- API: `api/assign_ticket.php`
|
||||||
|
- Dashboard: Shows assigned user in table
|
||||||
|
- Auto-saves on change
|
||||||
|
- Audit logged
|
||||||
|
|
||||||
|
**Code**: `views/TicketView.php:170-181`, `assets/js/ticket.js:handleAssignmentChange()`
|
||||||
|
|
||||||
|
### Feature 3: Status Transitions with Workflows
|
||||||
|
**Location**: Ticket view → Status dropdown (header)
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
- Database: `status_transitions` table defines allowed transitions
|
||||||
|
- Models: `WorkflowModel->isTransitionAllowed()`, `WorkflowModel->getAllowedTransitions()`
|
||||||
|
- Dropdown shows only valid transitions for current status
|
||||||
|
- Server-side validation prevents invalid changes
|
||||||
|
- Can require comments or admin privileges
|
||||||
|
- Removed from hamburger menu (was duplicate)
|
||||||
|
|
||||||
|
**Code**: `models/WorkflowModel.php`, `api/update_ticket.php:130-144`, `views/TicketView.php:185-198`
|
||||||
|
|
||||||
|
### Feature 4: Ticket Templates
|
||||||
|
**Location**: Create ticket page → Template selector
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
- Database: `ticket_templates` table
|
||||||
|
- Models: `TemplateModel->getAllTemplates()`, `TemplateModel->getTemplateById()`
|
||||||
|
- API: `api/get_template.php`
|
||||||
|
- JavaScript: `loadTemplate()` in dashboard.js
|
||||||
|
- Auto-fills: title, description, category, type, priority
|
||||||
|
- 4 default templates included
|
||||||
|
|
||||||
|
**Code**: `views/CreateTicketView.php:27-39`, `assets/js/dashboard.js:loadTemplate()`
|
||||||
|
|
||||||
|
### Feature 5: Bulk Actions (Admin Only)
|
||||||
|
**Location**: Dashboard → Checkboxes + Toolbar (admins only)
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
- Database: `bulk_operations` table tracks operations
|
||||||
|
- Models: `BulkOperationsModel->processBulkOperation()`
|
||||||
|
- API: `api/bulk_operation.php`
|
||||||
|
- UI: Toolbar appears when tickets selected
|
||||||
|
- Operations: Bulk close, bulk assign, bulk priority
|
||||||
|
- All operations audit logged
|
||||||
|
- Server-side admin validation
|
||||||
|
|
||||||
|
**Code**: `views/DashboardView.php:176-188`, `assets/js/dashboard.js:bulkClose()`, `models/BulkOperationsModel.php`
|
||||||
|
|
||||||
|
## Authentication & SSO Integration
|
||||||
|
|
||||||
|
### Authelia Integration
|
||||||
|
User information passed via HTTP headers:
|
||||||
|
- `Remote-User`: Username
|
||||||
|
- `Remote-Name`: Display name
|
||||||
|
- `Remote-Email`: Email
|
||||||
|
- `Remote-Groups`: Comma-separated groups
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
```php
|
||||||
|
$_SESSION['user'] = [
|
||||||
|
'user_id' => 123,
|
||||||
|
'username' => 'jared',
|
||||||
|
'display_name' => 'Jared Vititoe',
|
||||||
|
'email' => 'jared@lotusguild.org',
|
||||||
|
'is_admin' => true // true if 'admins' in Remote-Groups
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Privileges
|
||||||
|
- Bulk operations (close, assign, priority)
|
||||||
|
- Future: Admin-only transitions
|
||||||
|
|
||||||
|
## Frontend Components (Updated)
|
||||||
|
|
||||||
|
### Dashboard (`DashboardView.php` + `dashboard.js`)
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Sortable columns including new "Assigned To" column
|
||||||
|
- Search (title, description, ticket_id, category, type)
|
||||||
|
- Status filtering (default: Open + In Progress)
|
||||||
|
- Pagination (configurable)
|
||||||
|
- Dark mode toggle
|
||||||
|
- **Bulk Actions Toolbar** (admin only):
|
||||||
|
- Checkboxes on each ticket
|
||||||
|
- "Select All" checkbox
|
||||||
|
- Bulk close, assign, priority buttons
|
||||||
|
- Shows count of selected tickets
|
||||||
|
|
||||||
|
**Hamburger Menu**:
|
||||||
|
- Category/Type filtering
|
||||||
|
- Apply/Clear filters
|
||||||
|
- No status field (removed)
|
||||||
|
|
||||||
|
### Ticket View (`TicketView.php` + `ticket.js`)
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- **Tabbed Interface**: Description, Comments, Activity
|
||||||
|
- **Activity Timeline**: Complete audit trail with icons
|
||||||
|
- **Assignment Dropdown**: Assign to users
|
||||||
|
- **Status Dropdown**: Workflow-validated status changes (header)
|
||||||
|
- **Hamburger Menu**: Priority, Category, Type editing
|
||||||
|
- **Edit Button**: Title and description editing
|
||||||
|
- **Markdown Comments**: With live preview
|
||||||
|
- **Dark Mode**: Comprehensive support
|
||||||
|
|
||||||
|
**Visual Indicators**:
|
||||||
|
- Priority colors (P1=Red, P2=Orange, P3=Blue, P4=Green, P5=Gray)
|
||||||
|
- Status badges (Open=Green, In Progress=Yellow, Closed=Red, Resolved=Green)
|
||||||
|
- Priority border colors on ticket container
|
||||||
|
|
||||||
|
### Create Ticket (`CreateTicketView.php`)
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- **Template Selector**: Quick-fill from templates
|
||||||
|
- Standard fields: title, description, status, priority, category, type
|
||||||
|
- Form validation
|
||||||
|
- Discord webhook on creation
|
||||||
|
|
||||||
|
## Dark Mode (Fixed)
|
||||||
|
|
||||||
|
### Comprehensive Dark Mode CSS
|
||||||
|
**Files**: `assets/css/ticket.css`, `assets/css/dashboard.css`
|
||||||
|
|
||||||
|
**Colors**:
|
||||||
|
```css
|
||||||
|
body.dark-mode {
|
||||||
|
--bg-primary: #1a202c; /* Main background */
|
||||||
|
--bg-secondary: #2d3748; /* Cards, inputs */
|
||||||
|
--bg-tertiary: #4a5568; /* Hover states */
|
||||||
|
--text-primary: #e2e8f0; /* Main text */
|
||||||
|
--text-secondary: #cbd5e0; /* Secondary text */
|
||||||
|
--text-muted: #a0aec0; /* Muted text */
|
||||||
|
--border-color: #4a5568; /* Borders */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fixed Elements**:
|
||||||
|
- Timeline boxes (background + text)
|
||||||
|
- Bulk actions toolbar
|
||||||
|
- Tables and table rows
|
||||||
|
- Input fields and textareas
|
||||||
|
- Dropdowns and selects
|
||||||
|
- Comment boxes
|
||||||
|
- Modal dialogs
|
||||||
|
- All text elements
|
||||||
|
|
||||||
|
**Important**: Used `!important` flags to override any conflicting styles.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables (`.env`)
|
||||||
|
```ini
|
||||||
|
DB_HOST=10.10.10.50
|
||||||
|
DB_USER=tinkertickets
|
||||||
|
DB_PASS=password
|
||||||
|
DB_NAME=ticketing_system
|
||||||
|
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||||
|
```
|
||||||
|
|
||||||
|
**CRITICAL**: `.env` is gitignored! Never commit this file.
|
||||||
|
|
||||||
|
### Apache Configuration
|
||||||
|
**Virtual Host**: Apache serving from `/root/code/tinker_tickets`
|
||||||
|
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName t.lotusguild.org
|
||||||
|
DocumentRoot /root/code/tinker_tickets
|
||||||
|
|
||||||
|
<Directory /root/code/tinker_tickets>
|
||||||
|
Options -Indexes +FollowSymLinks
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /
|
||||||
|
RewriteRule ^ticket/([0-9]+)$ ticket.php?id=$1 [L,QSA]
|
||||||
|
RewriteRule ^ticket/create$ ticket.php?action=create [L,QSA]
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Git Auto-Deploy
|
||||||
|
**Repository**: https://code.lotusguild.org/LotusGuild/tinker_tickets
|
||||||
|
|
||||||
|
**Flow**:
|
||||||
|
1. Push to `main` branch
|
||||||
|
2. Auto-deploys to `/root/code/tinker_tickets` on 10.10.10.45
|
||||||
|
3. `.env` is preserved
|
||||||
|
4. Migrations must be run manually
|
||||||
|
|
||||||
|
### Running Migrations
|
||||||
|
```bash
|
||||||
|
cd /root/code/tinker_tickets/migrations
|
||||||
|
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 007_add_ticket_assignment.sql
|
||||||
|
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 008_add_status_workflows.sql
|
||||||
|
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 009_add_ticket_templates.sql
|
||||||
|
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 010_add_bulk_operations.sql
|
||||||
|
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 011_remove_view_tracking.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
- **PHP**: Tabs for indentation, prepared statements, `htmlspecialchars()` for output
|
||||||
|
- **JavaScript**: Vanilla JS, `fetch()` for AJAX, clear function names
|
||||||
|
- **CSS**: CSS variables for theming, mobile-responsive
|
||||||
|
- **Security**: No SQL injection, XSS prevention, session validation
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- APIs return JSON with `{success: bool, error: string}`
|
||||||
|
- Debug logging to `/tmp/api_debug.log` (update_ticket.php)
|
||||||
|
- User-friendly error messages
|
||||||
|
|
||||||
|
### Adding New Features
|
||||||
|
1. **Database**: Create migration in `migrations/`
|
||||||
|
2. **Model**: Add methods to relevant Model class
|
||||||
|
3. **API**: Create API endpoint in `api/` (with auth check)
|
||||||
|
4. **Controller**: Update controller to load data
|
||||||
|
5. **View**: Add UI elements
|
||||||
|
6. **JavaScript**: Add interactivity
|
||||||
|
7. **CSS**: Style for light + dark mode
|
||||||
|
8. **Test**: Test thoroughly before pushing
|
||||||
|
|
||||||
|
## ANSI Art Redesign (Next Priority)
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
Transform Tinker Tickets into a retro terminal aesthetic using ANSI art and ASCII characters.
|
||||||
|
|
||||||
|
### Design Concept
|
||||||
|
- **Terminal-style borders**: Use box-drawing characters (┌─┐│└─┘)
|
||||||
|
- **Monospace fonts**: Courier New, Consolas, Monaco
|
||||||
|
- **ASCII art headers**: Stylized "TINKER TICKETS" banner
|
||||||
|
- **Retro color palette**: Green terminal, amber terminal, or custom
|
||||||
|
- **Template objects**: Reusable border/box components
|
||||||
|
|
||||||
|
### Implementation Approach
|
||||||
|
1. **CSS Variables**: Define ANSI color palette
|
||||||
|
2. **Border Components**: Create CSS classes for boxes with ASCII borders
|
||||||
|
3. **Typography**: Monospace fonts throughout
|
||||||
|
4. **Icons**: Replace emoji with ASCII art
|
||||||
|
5. **Dashboard**: Terminal-style table with borders
|
||||||
|
6. **Tickets**: Box-drawing characters for sections
|
||||||
|
7. **Forms**: Terminal-style input boxes
|
||||||
|
|
||||||
|
### Reference Colors (Classic Terminal)
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--ansi-black: #000000;
|
||||||
|
--ansi-green: #00ff00;
|
||||||
|
--ansi-amber: #ffb000;
|
||||||
|
--ansi-blue: #0000ff;
|
||||||
|
--ansi-cyan: #00ffff;
|
||||||
|
--ansi-white: #ffffff;
|
||||||
|
--ansi-bg: #000000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Box Template
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ TICKET #123 │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ Title: Hardware Failure │
|
||||||
|
│ Status: [OPEN] │
|
||||||
|
│ Priority: P1 (CRITICAL) │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
```bash
|
||||||
|
# API debug logs
|
||||||
|
tail -f /tmp/api_debug.log
|
||||||
|
|
||||||
|
# Database connection
|
||||||
|
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system
|
||||||
|
|
||||||
|
# JavaScript console
|
||||||
|
# Open browser DevTools (F12) → Console tab
|
||||||
|
|
||||||
|
# Check dark mode
|
||||||
|
# localStorage.getItem('theme')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Known Behaviors
|
||||||
|
- Ticket viewing no longer tracked (011 migration removes view logs)
|
||||||
|
- Status can only be changed via header dropdown (removed from hamburger)
|
||||||
|
- Bulk actions only visible to admins
|
||||||
|
- Templates are optional when creating tickets
|
||||||
|
- Workflow validation prevents invalid status transitions
|
||||||
|
|
||||||
|
## Important Notes for AI Assistants
|
||||||
|
|
||||||
|
1. **All 5 features are complete and deployed**
|
||||||
|
2. **Dark mode is fixed** with comprehensive CSS
|
||||||
|
3. **Next priority is ANSI Art redesign** (major visual overhaul)
|
||||||
|
4. **Database at 10.10.10.50**, can't access directly from dev machine
|
||||||
|
5. **Auto-deploy is active**, test carefully before pushing
|
||||||
|
6. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
|
||||||
|
7. **API auth**: Check `$_SESSION['user']['user_id']` exists
|
||||||
|
8. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
|
||||||
|
9. **Config path**: `config/config.php` (not `config/db.php`)
|
||||||
|
10. **Migrations**: Must be run manually on database server
|
||||||
|
|
||||||
|
## File Reference Quick Guide
|
||||||
|
|
||||||
|
| File | Purpose | Key Functions |
|
||||||
|
|------|---------|---------------|
|
||||||
|
| `index.php` | Dashboard router | Database connection, routing |
|
||||||
|
| `ticket.php` | Ticket router | View/create ticket routing |
|
||||||
|
| `api/update_ticket.php` | Update API | Workflow validation, partial updates |
|
||||||
|
| `api/assign_ticket.php` | Assignment API | Assign/unassign tickets |
|
||||||
|
| `api/bulk_operation.php` | Bulk ops API | Admin bulk operations |
|
||||||
|
| `api/get_template.php` | Template API | Fetch ticket templates |
|
||||||
|
| `api/get_users.php` | Users API | Get user list |
|
||||||
|
| `models/TicketModel.php` | Ticket data | CRUD, assignment, filtering |
|
||||||
|
| `models/WorkflowModel.php` | Workflow rules | Status transition validation |
|
||||||
|
| `models/AuditLogModel.php` | Audit logging | Timeline, activity tracking |
|
||||||
|
| `models/TemplateModel.php` | Templates | Template CRUD |
|
||||||
|
| `models/BulkOperationsModel.php` | Bulk ops | Process bulk operations |
|
||||||
|
| `controllers/DashboardController.php` | Dashboard logic | Pagination, filters, assigned column |
|
||||||
|
| `controllers/TicketController.php` | Ticket logic | CRUD, timeline, templates |
|
||||||
|
| `assets/js/dashboard.js` | Dashboard UI | Filters, bulk actions, templates |
|
||||||
|
| `assets/js/ticket.js` | Ticket UI | Status updates, assignment, comments |
|
||||||
|
| `assets/css/dashboard.css` | Dashboard styles | Layout, table, bulk toolbar, dark mode |
|
||||||
|
| `assets/css/ticket.css` | Ticket styles | Timeline, ticket view, dark mode |
|
||||||
|
|
||||||
|
## Repository & Contact
|
||||||
|
|
||||||
|
- **Gitea**: https://code.lotusguild.org/LotusGuild/tinker_tickets
|
||||||
|
- **Production**: http://t.lotusguild.org
|
||||||
|
- **Infrastructure**: LotusGuild data center management
|
||||||
288
README.md
288
README.md
@@ -1,38 +1,274 @@
|
|||||||
# Tinker Tickets
|
# Tinker Tickets
|
||||||
|
|
||||||
A lightweight PHP-based ticketing system designed for tracking and managing data center infrastructure issues.
|
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management.
|
||||||
|
|
||||||
## Features
|
## ✨ Core Features
|
||||||
|
|
||||||
- 📊 Clean dashboard interface with sortable columns
|
### 📊 Dashboard & Ticket Management
|
||||||
- 🎫 Customizable ticket creation and management
|
- **Smart Dashboard**: Sortable columns, advanced filtering by status/priority/category/type
|
||||||
- 🔄 Real-time status updates and priority tracking
|
- **Full-Text Search**: Search across tickets, descriptions, and metadata
|
||||||
- 💬 Markdown-supported commenting system
|
- **Ticket Assignment**: Assign tickets to specific users with "Assigned To" column
|
||||||
- 🔔 Discord integration for notifications
|
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators
|
||||||
- 📱 Mobile-responsive design
|
- **Custom Categories**: Hardware, Software, Network, Security, General
|
||||||
|
- **Ticket Types**: Maintenance, Install, Task, Upgrade, Issue, Problem
|
||||||
|
|
||||||
## Core Components
|
### 🔄 Workflow Management
|
||||||
|
- **Status Transitions**: Enforced workflow rules (Open → In Progress → Resolved → Closed)
|
||||||
|
- **Workflow Validation**: Server-side validation prevents invalid status changes
|
||||||
|
- **Admin Controls**: Certain transitions can require admin privileges
|
||||||
|
- **Comment Requirements**: Optional comment requirements for specific transitions
|
||||||
|
- **Activity Timeline**: Complete audit trail of all ticket changes
|
||||||
|
|
||||||
- **Dashboard**: View and filter tickets by status, priority, and type
|
### 💬 Collaboration Features
|
||||||
- **Ticket Management**: Create, edit, and update ticket details
|
- **Markdown Comments**: Full Markdown support with live preview
|
||||||
- **Priority Levels**: P1 (Critical) to P4 (Low) impact tracking
|
- **User Tracking**: Tracks who created, updated, and assigned tickets
|
||||||
- **Categories**: Hardware, Software, Network, Security tracking
|
- **Activity Timeline**: Shows all ticket events (creates, updates, assignments, comments)
|
||||||
- **Comment System**: Markdown support for detailed documentation
|
- **Real-time Updates**: AJAX-powered updates without page refreshes
|
||||||
|
|
||||||
## Technical Details
|
### 🎫 Ticket Templates
|
||||||
|
- **Quick Creation**: Pre-configured templates for common issues
|
||||||
|
- **Default Templates**: Hardware Failure, Software Installation, Network Issue, Maintenance
|
||||||
|
- **Auto-fill**: Templates populate title, description, category, type, and priority
|
||||||
|
|
||||||
- Backend: PHP with MySQL database
|
### 👥 User Management & Authentication
|
||||||
- Frontend: HTML5, CSS3, JavaScript
|
- **SSO Integration**: Authelia authentication with LLDAP backend
|
||||||
- Authentication: Environment-based configuration
|
- **Role-Based Access**: Admin and standard user roles
|
||||||
- API: RESTful endpoints for ticket operations
|
- **User Display Names**: Support for display names and usernames
|
||||||
|
- **Session Management**: Secure PHP session handling
|
||||||
|
|
||||||
## Configuration
|
### ⚡ Bulk Actions (Admin Only)
|
||||||
|
- **Bulk Close**: Close multiple tickets at once
|
||||||
|
- **Bulk Assign**: Assign multiple tickets to a user
|
||||||
|
- **Bulk Priority**: Change priority for multiple tickets
|
||||||
|
- **Operation Tracking**: All bulk operations logged in audit trail
|
||||||
|
|
||||||
1. Create `.env` file with database credentials:
|
### 🔔 Notifications
|
||||||
|
- **Discord Integration**: Webhook notifications for ticket creation and updates
|
||||||
|
- **Rich Embeds**: Color-coded priority indicators and ticket links
|
||||||
|
- **Change Tracking**: Detailed notification of what changed
|
||||||
|
|
||||||
|
### 🎨 User Interface
|
||||||
|
- **Dark Mode**: Full dark mode support with proper contrast
|
||||||
|
- **Responsive Design**: Works on desktop and mobile devices
|
||||||
|
- **Clean Layout**: Modern, intuitive interface
|
||||||
|
- **Hamburger Menu**: Quick access to ticket actions (priority, category, type)
|
||||||
|
|
||||||
|
## 🏗️ Technical Architecture
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Language**: PHP 7.4+
|
||||||
|
- **Database**: MariaDB/MySQL
|
||||||
|
- **Architecture**: MVC pattern with models, views, controllers
|
||||||
|
- **ORM**: Custom database abstraction layer
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **HTML5/CSS3**: Semantic markup with modern CSS
|
||||||
|
- **JavaScript**: Vanilla JS with Fetch API for AJAX
|
||||||
|
- **Markdown**: marked.js for Markdown rendering
|
||||||
|
- **Icons**: Unicode emoji icons
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
- **tickets**: Core ticket data with user tracking
|
||||||
|
- **comments**: Markdown-supported comments
|
||||||
|
- **users**: User accounts synced from LLDAP
|
||||||
|
- **audit_log**: Complete audit trail with JSON details
|
||||||
|
- **status_transitions**: Workflow configuration
|
||||||
|
- **ticket_templates**: Reusable ticket templates
|
||||||
|
- **bulk_operations**: Tracking for bulk admin operations
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
- `/api/update_ticket.php` - Update ticket with workflow validation
|
||||||
|
- `/api/assign_ticket.php` - Assign ticket to user
|
||||||
|
- `/api/add_comment.php` - Add comment to ticket
|
||||||
|
- `/api/get_template.php` - Fetch ticket template
|
||||||
|
- `/api/get_users.php` - Get user list for assignments
|
||||||
|
- `/api/bulk_operation.php` - Perform bulk operations (admin only)
|
||||||
|
|
||||||
|
## 🚀 Setup & Configuration
|
||||||
|
|
||||||
|
### 1. Environment Configuration
|
||||||
|
|
||||||
|
Create `.env` file in project root:
|
||||||
```env
|
```env
|
||||||
DB_HOST=localhost
|
DB_HOST=10.10.10.50
|
||||||
DB_USER=username
|
DB_USER=tinkertickets
|
||||||
DB_PASS=password
|
DB_PASS=your_password
|
||||||
DB_NAME=database
|
DB_NAME=ticketing_system
|
||||||
DISCORD_WEBHOOK_URL=your_webhook_url
|
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 2. Database Setup
|
||||||
|
|
||||||
|
Run migrations in order:
|
||||||
|
```bash
|
||||||
|
# Navigate to project directory
|
||||||
|
cd /root/code/tinker_tickets
|
||||||
|
|
||||||
|
# Run each migration
|
||||||
|
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/001_initial_schema.sql
|
||||||
|
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/007_add_ticket_assignment.sql
|
||||||
|
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/008_add_status_workflows.sql
|
||||||
|
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/009_add_ticket_templates.sql
|
||||||
|
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/010_add_bulk_operations.sql
|
||||||
|
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/011_remove_view_tracking.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Web Server Configuration
|
||||||
|
|
||||||
|
**Apache Configuration** (recommended):
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName t.lotusguild.org
|
||||||
|
DocumentRoot /root/code/tinker_tickets
|
||||||
|
|
||||||
|
<Directory /root/code/tinker_tickets>
|
||||||
|
Options -Indexes +FollowSymLinks
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
|
||||||
|
# Enable mod_rewrite for clean URLs
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /
|
||||||
|
|
||||||
|
# Route ticket URLs
|
||||||
|
RewriteRule ^ticket/([0-9]+)$ ticket.php?id=$1 [L,QSA]
|
||||||
|
|
||||||
|
# Route ticket create
|
||||||
|
RewriteRule ^ticket/create$ ticket.php?action=create [L,QSA]
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Authelia Integration
|
||||||
|
|
||||||
|
Tinker Tickets uses Authelia for SSO. User information is passed via headers:
|
||||||
|
- `Remote-User`: Username
|
||||||
|
- `Remote-Name`: Display name
|
||||||
|
- `Remote-Email`: Email address
|
||||||
|
- `Remote-Groups`: User groups (comma-separated)
|
||||||
|
|
||||||
|
Admin users must be in the `admins` group in LLDAP.
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tinker_tickets/
|
||||||
|
├── api/ # API endpoints
|
||||||
|
│ ├── add_comment.php
|
||||||
|
│ ├── assign_ticket.php
|
||||||
|
│ ├── bulk_operation.php
|
||||||
|
│ ├── get_template.php
|
||||||
|
│ ├── get_users.php
|
||||||
|
│ └── update_ticket.php
|
||||||
|
├── assets/ # Static assets
|
||||||
|
│ ├── css/
|
||||||
|
│ │ ├── dashboard.css
|
||||||
|
│ │ └── ticket.css
|
||||||
|
│ └── js/
|
||||||
|
│ ├── dashboard.js
|
||||||
|
│ └── ticket.js
|
||||||
|
├── config/ # Configuration
|
||||||
|
│ └── config.php
|
||||||
|
├── controllers/ # MVC Controllers
|
||||||
|
│ ├── DashboardController.php
|
||||||
|
│ └── TicketController.php
|
||||||
|
├── models/ # Data models
|
||||||
|
│ ├── AuditLogModel.php
|
||||||
|
│ ├── BulkOperationsModel.php
|
||||||
|
│ ├── CommentModel.php
|
||||||
|
│ ├── TemplateModel.php
|
||||||
|
│ ├── TicketModel.php
|
||||||
|
│ ├── UserModel.php
|
||||||
|
│ └── WorkflowModel.php
|
||||||
|
├── views/ # View templates
|
||||||
|
│ ├── CreateTicketView.php
|
||||||
|
│ ├── DashboardView.php
|
||||||
|
│ └── TicketView.php
|
||||||
|
├── migrations/ # Database migrations
|
||||||
|
│ ├── 001_initial_schema.sql
|
||||||
|
│ ├── 007_add_ticket_assignment.sql
|
||||||
|
│ ├── 008_add_status_workflows.sql
|
||||||
|
│ ├── 009_add_ticket_templates.sql
|
||||||
|
│ ├── 010_add_bulk_operations.sql
|
||||||
|
│ └── 011_remove_view_tracking.sql
|
||||||
|
├── index.php # Dashboard entry point
|
||||||
|
├── ticket.php # Ticket view/create entry point
|
||||||
|
└── .env # Environment configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Security Features
|
||||||
|
|
||||||
|
- **SQL Injection Prevention**: All queries use prepared statements
|
||||||
|
- **XSS Protection**: All output is properly escaped with `htmlspecialchars()`
|
||||||
|
- **Session Security**: Secure PHP session handling
|
||||||
|
- **Admin Validation**: Server-side admin checks for privileged operations
|
||||||
|
- **Workflow Enforcement**: Status transitions validated server-side
|
||||||
|
- **Audit Logging**: Complete audit trail of all actions
|
||||||
|
|
||||||
|
## 🎯 Workflow States
|
||||||
|
|
||||||
|
### Default Workflow
|
||||||
|
```
|
||||||
|
Open → In Progress → Resolved → Closed
|
||||||
|
↓ ↓ ↓
|
||||||
|
└─────────┴──────────┘
|
||||||
|
(can reopen)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow Configuration
|
||||||
|
Status transitions are defined in the `status_transitions` table:
|
||||||
|
- `from_status`: Current status
|
||||||
|
- `to_status`: Target status
|
||||||
|
- `requires_comment`: Whether transition requires a comment
|
||||||
|
- `requires_admin`: Whether transition requires admin privileges
|
||||||
|
- `is_active`: Whether transition is enabled
|
||||||
|
|
||||||
|
## 📝 Usage Examples
|
||||||
|
|
||||||
|
### Creating a Ticket
|
||||||
|
1. Click "New Ticket" button
|
||||||
|
2. Select template (optional) - auto-fills common fields
|
||||||
|
3. Fill in title, description, category, type, priority
|
||||||
|
4. Click "Create Ticket"
|
||||||
|
|
||||||
|
### Updating Ticket Status
|
||||||
|
1. Open ticket
|
||||||
|
2. Click status dropdown (next to priority badge)
|
||||||
|
3. Select allowed status (workflow-validated)
|
||||||
|
4. Confirm if comment is required
|
||||||
|
|
||||||
|
### Assigning Tickets
|
||||||
|
1. Open ticket or use dashboard bulk actions
|
||||||
|
2. Select user from "Assigned to" dropdown
|
||||||
|
3. Changes are auto-saved
|
||||||
|
|
||||||
|
### Bulk Operations (Admin Only)
|
||||||
|
1. Check multiple tickets on dashboard
|
||||||
|
2. Select bulk action (Close, Assign, Change Priority)
|
||||||
|
3. Complete operation
|
||||||
|
4. All actions are logged in audit trail
|
||||||
|
|
||||||
|
## 🔮 Roadmap
|
||||||
|
|
||||||
|
- ✅ Activity Timeline
|
||||||
|
- ✅ Ticket Assignment
|
||||||
|
- ✅ Status Transitions with Workflows
|
||||||
|
- ✅ Ticket Templates
|
||||||
|
- ✅ Bulk Actions (Admin Only)
|
||||||
|
- 🎨 **ANSI Art Redesign** (Next Priority)
|
||||||
|
- 🔗 Ticket Dependencies (blocks/blocked by)
|
||||||
|
- 📊 Custom Dashboard Widgets
|
||||||
|
- 🔧 Custom Fields per Category
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
This is an internal tool for LotusGuild infrastructure management. For feature requests or bug reports, contact the infrastructure team.
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
Internal use only - LotusGuild Infrastructure
|
||||||
|
|
||||||
|
## 🙏 Credits
|
||||||
|
|
||||||
|
Built with ❤️ for the LotusGuild community
|
||||||
|
Powered by PHP, MariaDB, and lots of coffee ☕
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ try {
|
|||||||
// Include required files with proper error handling
|
// Include required files with proper error handling
|
||||||
$configPath = dirname(__DIR__) . '/config/config.php';
|
$configPath = dirname(__DIR__) . '/config/config.php';
|
||||||
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
if (!file_exists($configPath)) {
|
if (!file_exists($configPath)) {
|
||||||
throw new Exception("Config file not found: $configPath");
|
throw new Exception("Config file not found: $configPath");
|
||||||
@@ -21,6 +22,28 @@ try {
|
|||||||
|
|
||||||
require_once $configPath;
|
require_once $configPath;
|
||||||
require_once $commentModelPath;
|
require_once $commentModelPath;
|
||||||
|
require_once $auditLogModelPath;
|
||||||
|
|
||||||
|
// Check authentication via session
|
||||||
|
session_start();
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
throw new Exception("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentUser = $_SESSION['user'];
|
||||||
|
$userId = $currentUser['user_id'];
|
||||||
|
|
||||||
// Create database connection
|
// Create database connection
|
||||||
$conn = new mysqli(
|
$conn = new mysqli(
|
||||||
@@ -43,11 +66,22 @@ try {
|
|||||||
|
|
||||||
$ticketId = $data['ticket_id'];
|
$ticketId = $data['ticket_id'];
|
||||||
|
|
||||||
// Initialize CommentModel directly
|
// Initialize models
|
||||||
$commentModel = new CommentModel($conn);
|
$commentModel = new CommentModel($conn);
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
|
||||||
// Add comment
|
// Add comment with user tracking
|
||||||
$result = $commentModel->addComment($ticketId, $data);
|
$result = $commentModel->addComment($ticketId, $data, $userId);
|
||||||
|
|
||||||
|
// Log comment creation to audit log
|
||||||
|
if ($result['success'] && isset($result['comment_id'])) {
|
||||||
|
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user display name to result for frontend
|
||||||
|
if ($result['success']) {
|
||||||
|
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
|
||||||
|
}
|
||||||
|
|
||||||
// Discard any unexpected output
|
// Discard any unexpected output
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
|
|||||||
70
api/assign_ticket.php
Normal file
70
api/assign_ticket.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $_SESSION['user']['user_id'];
|
||||||
|
|
||||||
|
// Get request data
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$ticketId = $data['ticket_id'] ?? null;
|
||||||
|
$assignedTo = $data['assigned_to'] ?? null;
|
||||||
|
|
||||||
|
if (!$ticketId) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create database connection
|
||||||
|
$conn = new mysqli(
|
||||||
|
$GLOBALS['config']['DB_HOST'],
|
||||||
|
$GLOBALS['config']['DB_USER'],
|
||||||
|
$GLOBALS['config']['DB_PASS'],
|
||||||
|
$GLOBALS['config']['DB_NAME']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($conn->connect_error) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$auditLogModel = new AuditLogModel($conn);
|
||||||
|
|
||||||
|
if ($assignedTo === null || $assignedTo === '') {
|
||||||
|
// Unassign ticket
|
||||||
|
$success = $ticketModel->unassignTicket($ticketId, $userId);
|
||||||
|
if ($success) {
|
||||||
|
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Assign ticket
|
||||||
|
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
|
||||||
|
if ($success) {
|
||||||
|
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
|
echo json_encode(['success' => $success]);
|
||||||
136
api/audit_log.php
Normal file
136
api/audit_log.php
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Audit Log API Endpoint
|
||||||
|
* Handles fetching filtered audit logs and CSV export
|
||||||
|
* Admin-only access
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin status - audit log viewing is admin-only
|
||||||
|
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||||
|
if (!$isAdmin) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create database connection
|
||||||
|
$conn = new mysqli(
|
||||||
|
$GLOBALS['config']['DB_HOST'],
|
||||||
|
$GLOBALS['config']['DB_USER'],
|
||||||
|
$GLOBALS['config']['DB_PASS'],
|
||||||
|
$GLOBALS['config']['DB_NAME']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($conn->connect_error) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditLogModel = new AuditLogModel($conn);
|
||||||
|
|
||||||
|
// GET - Fetch filtered audit logs or export to CSV
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||||
|
// Check for CSV export request
|
||||||
|
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
|
||||||
|
// Build filters
|
||||||
|
$filters = [];
|
||||||
|
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
|
||||||
|
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
|
||||||
|
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
|
||||||
|
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
|
||||||
|
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
|
||||||
|
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
|
||||||
|
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
|
||||||
|
|
||||||
|
// Get all matching logs (no limit for CSV export)
|
||||||
|
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
|
||||||
|
$logs = $result['logs'];
|
||||||
|
|
||||||
|
// Set CSV headers
|
||||||
|
header('Content-Type: text/csv');
|
||||||
|
header('Content-Disposition: attachment; filename="audit_log_' . date('Y-m-d_His') . '.csv"');
|
||||||
|
|
||||||
|
// Output CSV
|
||||||
|
$output = fopen('php://output', 'w');
|
||||||
|
|
||||||
|
// Write CSV header
|
||||||
|
fputcsv($output, ['Log ID', 'Timestamp', 'User', 'Action', 'Entity Type', 'Entity ID', 'IP Address', 'Details']);
|
||||||
|
|
||||||
|
// Write data rows
|
||||||
|
foreach ($logs as $log) {
|
||||||
|
$details = '';
|
||||||
|
if (is_array($log['details'])) {
|
||||||
|
$details = json_encode($log['details']);
|
||||||
|
}
|
||||||
|
|
||||||
|
fputcsv($output, [
|
||||||
|
$log['log_id'],
|
||||||
|
$log['created_at'],
|
||||||
|
$log['display_name'] ?? $log['username'] ?? 'N/A',
|
||||||
|
$log['action_type'],
|
||||||
|
$log['entity_type'],
|
||||||
|
$log['entity_id'] ?? 'N/A',
|
||||||
|
$log['ip_address'] ?? 'N/A',
|
||||||
|
$details
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($output);
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal JSON response for filtered logs
|
||||||
|
try {
|
||||||
|
// Get pagination parameters
|
||||||
|
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
||||||
|
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 50;
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
// Build filters
|
||||||
|
$filters = [];
|
||||||
|
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
|
||||||
|
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
|
||||||
|
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
|
||||||
|
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
|
||||||
|
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
|
||||||
|
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
|
||||||
|
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
|
||||||
|
|
||||||
|
// Get filtered logs
|
||||||
|
$result = $auditLogModel->getFilteredLogs($filters, $limit, $offset);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'logs' => $result['logs'],
|
||||||
|
'total' => $result['total'],
|
||||||
|
'pages' => $result['pages'],
|
||||||
|
'current_page' => $page
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to fetch audit logs']);
|
||||||
|
}
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method not allowed
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
$conn->close();
|
||||||
|
?>
|
||||||
96
api/bulk_operation.php
Normal file
96
api/bulk_operation.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/BulkOperationsModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin status - bulk operations are admin-only
|
||||||
|
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||||
|
if (!$isAdmin) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get request data
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$operationType = $data['operation_type'] ?? null;
|
||||||
|
$ticketIds = $data['ticket_ids'] ?? [];
|
||||||
|
$parameters = $data['parameters'] ?? null;
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!$operationType || empty($ticketIds)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ticket IDs are integers
|
||||||
|
foreach ($ticketIds as $ticketId) {
|
||||||
|
if (!is_numeric($ticketId)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID format']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create database connection
|
||||||
|
$conn = new mysqli(
|
||||||
|
$GLOBALS['config']['DB_HOST'],
|
||||||
|
$GLOBALS['config']['DB_USER'],
|
||||||
|
$GLOBALS['config']['DB_PASS'],
|
||||||
|
$GLOBALS['config']['DB_NAME']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($conn->connect_error) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bulkOpsModel = new BulkOperationsModel($conn);
|
||||||
|
|
||||||
|
// Create bulk operation record
|
||||||
|
$operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters);
|
||||||
|
|
||||||
|
if (!$operationId) {
|
||||||
|
$conn->close();
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to create bulk operation']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the bulk operation
|
||||||
|
$result = $bulkOpsModel->processBulkOperation($operationId);
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
|
if (isset($result['error'])) {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $result['error']
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'operation_id' => $operationId,
|
||||||
|
'processed' => $result['processed'],
|
||||||
|
'failed' => $result['failed'],
|
||||||
|
'message' => "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed"
|
||||||
|
]);
|
||||||
|
}
|
||||||
45
api/get_template.php
Normal file
45
api/get_template.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get template ID from query parameter
|
||||||
|
$templateId = $_GET['template_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$templateId) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Template ID required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create database connection
|
||||||
|
$conn = new mysqli(
|
||||||
|
$GLOBALS['config']['DB_HOST'],
|
||||||
|
$GLOBALS['config']['DB_USER'],
|
||||||
|
$GLOBALS['config']['DB_PASS'],
|
||||||
|
$GLOBALS['config']['DB_NAME']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($conn->connect_error) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get template
|
||||||
|
$templateModel = new TemplateModel($conn);
|
||||||
|
$template = $templateModel->getTemplateById($templateId);
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
|
if ($template) {
|
||||||
|
echo json_encode(['success' => true, 'template' => $template]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Template not found']);
|
||||||
|
}
|
||||||
33
api/get_users.php
Normal file
33
api/get_users.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create database connection
|
||||||
|
$conn = new mysqli(
|
||||||
|
$GLOBALS['config']['DB_HOST'],
|
||||||
|
$GLOBALS['config']['DB_USER'],
|
||||||
|
$GLOBALS['config']['DB_PASS'],
|
||||||
|
$GLOBALS['config']['DB_NAME']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($conn->connect_error) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all users
|
||||||
|
$userModel = new UserModel($conn);
|
||||||
|
$users = $userModel->getAllUsers();
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'users' => $users]);
|
||||||
190
api/saved_filters.php
Normal file
190
api/saved_filters.php
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Saved Filters API Endpoint
|
||||||
|
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/SavedFiltersModel.php';
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $_SESSION['user']['user_id'];
|
||||||
|
|
||||||
|
// Create database connection
|
||||||
|
$conn = new mysqli(
|
||||||
|
$GLOBALS['config']['DB_HOST'],
|
||||||
|
$GLOBALS['config']['DB_USER'],
|
||||||
|
$GLOBALS['config']['DB_PASS'],
|
||||||
|
$GLOBALS['config']['DB_NAME']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($conn->connect_error) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filtersModel = new SavedFiltersModel($conn);
|
||||||
|
|
||||||
|
// GET - Fetch all saved filters or a specific filter
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||||
|
try {
|
||||||
|
if (isset($_GET['filter_id'])) {
|
||||||
|
$filterId = (int)$_GET['filter_id'];
|
||||||
|
$filter = $filtersModel->getFilter($filterId, $userId);
|
||||||
|
|
||||||
|
if ($filter) {
|
||||||
|
echo json_encode(['success' => true, 'filter' => $filter]);
|
||||||
|
} else {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Filter not found']);
|
||||||
|
}
|
||||||
|
} else if (isset($_GET['default'])) {
|
||||||
|
// Get default filter
|
||||||
|
$filter = $filtersModel->getDefaultFilter($userId);
|
||||||
|
echo json_encode(['success' => true, 'filter' => $filter]);
|
||||||
|
} else {
|
||||||
|
// Get all filters
|
||||||
|
$filters = $filtersModel->getUserFilters($userId);
|
||||||
|
echo json_encode(['success' => true, 'filters' => $filters]);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']);
|
||||||
|
}
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST - Create a new saved filter
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filterName = trim($data['filter_name']);
|
||||||
|
$filterCriteria = $data['filter_criteria'];
|
||||||
|
$isDefault = $data['is_default'] ?? false;
|
||||||
|
|
||||||
|
// Validate filter name
|
||||||
|
if (empty($filterName) || strlen($filterName) > 100) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid filter name']);
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault);
|
||||||
|
echo json_encode($result);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to save filter']);
|
||||||
|
}
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT - Update an existing filter
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!isset($data['filter_id'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filterId = (int)$data['filter_id'];
|
||||||
|
|
||||||
|
// Handle setting default filter
|
||||||
|
if (isset($data['set_default']) && $data['set_default'] === true) {
|
||||||
|
try {
|
||||||
|
$result = $filtersModel->setDefaultFilter($filterId, $userId);
|
||||||
|
echo json_encode($result);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to set default filter']);
|
||||||
|
}
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle full filter update
|
||||||
|
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filterName = trim($data['filter_name']);
|
||||||
|
$filterCriteria = $data['filter_criteria'];
|
||||||
|
$isDefault = $data['is_default'] ?? false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault);
|
||||||
|
echo json_encode($result);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to update filter']);
|
||||||
|
}
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE - Delete a saved filter
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!isset($data['filter_id'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filterId = (int)$data['filter_id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $filtersModel->deleteFilter($filterId, $userId);
|
||||||
|
echo json_encode($result);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to delete filter']);
|
||||||
|
}
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method not allowed
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
$conn->close();
|
||||||
|
?>
|
||||||
@@ -28,7 +28,14 @@ try {
|
|||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
|
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
|
||||||
list($key, $value) = explode('=', $line, 2);
|
list($key, $value) = explode('=', $line, 2);
|
||||||
$envVars[trim($key)] = trim($value);
|
$key = trim($key);
|
||||||
|
$value = trim($value);
|
||||||
|
// Remove surrounding quotes if present
|
||||||
|
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||||
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
||||||
|
$value = substr($value, 1, -1);
|
||||||
|
}
|
||||||
|
$envVars[$key] = $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
debug_log("Environment variables loaded");
|
debug_log("Environment variables loaded");
|
||||||
@@ -37,22 +44,57 @@ try {
|
|||||||
// Load models directly with absolute paths
|
// Load models directly with absolute paths
|
||||||
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
|
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
$workflowModelPath = dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||||
|
|
||||||
debug_log("Loading models from: $ticketModelPath and $commentModelPath");
|
debug_log("Loading models from: $ticketModelPath and $commentModelPath");
|
||||||
require_once $ticketModelPath;
|
require_once $ticketModelPath;
|
||||||
require_once $commentModelPath;
|
require_once $commentModelPath;
|
||||||
|
require_once $auditLogModelPath;
|
||||||
|
require_once $workflowModelPath;
|
||||||
debug_log("Models loaded successfully");
|
debug_log("Models loaded successfully");
|
||||||
|
|
||||||
|
// Check authentication via session
|
||||||
|
session_start();
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
throw new Exception("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT') {
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentUser = $_SESSION['user'];
|
||||||
|
$userId = $currentUser['user_id'];
|
||||||
|
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||||
|
debug_log("User authenticated: " . $currentUser['username'] . " (admin: " . ($isAdmin ? 'yes' : 'no') . ")");
|
||||||
|
|
||||||
// Updated controller class that handles partial updates
|
// Updated controller class that handles partial updates
|
||||||
class ApiTicketController {
|
class ApiTicketController {
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
private $commentModel;
|
private $commentModel;
|
||||||
|
private $auditLog;
|
||||||
|
private $workflowModel;
|
||||||
private $envVars;
|
private $envVars;
|
||||||
|
private $userId;
|
||||||
|
private $isAdmin;
|
||||||
|
|
||||||
public function __construct($conn, $envVars = []) {
|
public function __construct($conn, $envVars = [], $userId = null, $isAdmin = false) {
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
|
$this->auditLog = new AuditLogModel($conn);
|
||||||
|
$this->workflowModel = new WorkflowModel($conn);
|
||||||
$this->envVars = $envVars;
|
$this->envVars = $envVars;
|
||||||
|
$this->userId = $userId;
|
||||||
|
$this->isAdmin = $isAdmin;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update($id, $data) {
|
public function update($id, $data) {
|
||||||
@@ -98,25 +140,37 @@ try {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate status
|
// Validate status transition using workflow model
|
||||||
$validStatuses = ['Open', 'Closed', 'In Progress', 'Pending'];
|
if ($currentTicket['status'] !== $updateData['status']) {
|
||||||
if (!in_array($updateData['status'], $validStatuses)) {
|
$allowed = $this->workflowModel->isTransitionAllowed(
|
||||||
|
$currentTicket['status'],
|
||||||
|
$updateData['status'],
|
||||||
|
$this->isAdmin
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$allowed) {
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'Invalid status value'
|
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
debug_log("Validation passed, calling ticketModel->updateTicket");
|
debug_log("Validation passed, calling ticketModel->updateTicket");
|
||||||
|
|
||||||
// Update ticket
|
// Update ticket with user tracking
|
||||||
$result = $this->ticketModel->updateTicket($updateData);
|
$result = $this->ticketModel->updateTicket($updateData, $this->userId);
|
||||||
|
|
||||||
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
|
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
// Send Discord webhook notification
|
// Log ticket update to audit log
|
||||||
$this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
|
if ($this->userId) {
|
||||||
|
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord webhook disabled for updates - only send for new tickets
|
||||||
|
// $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -259,7 +313,7 @@ try {
|
|||||||
|
|
||||||
// Initialize controller
|
// Initialize controller
|
||||||
debug_log("Initializing controller");
|
debug_log("Initializing controller");
|
||||||
$controller = new ApiTicketController($conn, $envVars);
|
$controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin);
|
||||||
debug_log("Controller initialized");
|
debug_log("Controller initialized");
|
||||||
|
|
||||||
// Update ticket
|
// Update ticket
|
||||||
|
|||||||
136
api/user_preferences.php
Normal file
136
api/user_preferences.php
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* User Preferences API Endpoint
|
||||||
|
* Handles GET (fetch preferences) and POST (update preference)
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $_SESSION['user']['user_id'];
|
||||||
|
|
||||||
|
// Create database connection
|
||||||
|
$conn = new mysqli(
|
||||||
|
$GLOBALS['config']['DB_HOST'],
|
||||||
|
$GLOBALS['config']['DB_USER'],
|
||||||
|
$GLOBALS['config']['DB_PASS'],
|
||||||
|
$GLOBALS['config']['DB_NAME']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($conn->connect_error) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefsModel = new UserPreferencesModel($conn);
|
||||||
|
|
||||||
|
// GET - Fetch all preferences for user
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||||
|
try {
|
||||||
|
$prefs = $prefsModel->getUserPreferences($userId);
|
||||||
|
echo json_encode(['success' => true, 'preferences' => $prefs]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to fetch preferences']);
|
||||||
|
}
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST - Update a preference
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!isset($data['key']) || !isset($data['value'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing key or value']);
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = trim($data['key']);
|
||||||
|
$value = $data['value'];
|
||||||
|
|
||||||
|
// Validate preference key (whitelist)
|
||||||
|
$validKeys = [
|
||||||
|
'rows_per_page',
|
||||||
|
'default_status_filters',
|
||||||
|
'table_density',
|
||||||
|
'notifications_enabled',
|
||||||
|
'sound_effects',
|
||||||
|
'toast_duration'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!in_array($key, $validKeys)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid preference key']);
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$success = $prefsModel->setPreference($userId, $key, $value);
|
||||||
|
|
||||||
|
// Also update cookie for rows_per_page for backwards compatibility
|
||||||
|
if ($key === 'rows_per_page') {
|
||||||
|
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => $success]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to save preference']);
|
||||||
|
}
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE - Delete a preference (optional endpoint)
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!isset($data['key'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing key']);
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$success = $prefsModel->deletePreference($userId, $data['key']);
|
||||||
|
echo json_encode(['success' => $success]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to delete preference']);
|
||||||
|
}
|
||||||
|
$conn->close();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method not allowed
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
$conn->close();
|
||||||
|
?>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
367
assets/js/advanced-search.js
Normal file
367
assets/js/advanced-search.js
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
/**
|
||||||
|
* Advanced Search Functionality
|
||||||
|
* Handles complex search queries with date ranges, user filters, and multiple criteria
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Open advanced search modal
|
||||||
|
function openAdvancedSearch() {
|
||||||
|
const modal = document.getElementById('advancedSearchModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
loadUsersForSearch();
|
||||||
|
populateCurrentFilters();
|
||||||
|
loadSavedFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close advanced search modal
|
||||||
|
function closeAdvancedSearch() {
|
||||||
|
const modal = document.getElementById('advancedSearchModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking on backdrop
|
||||||
|
function closeOnAdvancedSearchBackdropClick(event) {
|
||||||
|
const modal = document.getElementById('advancedSearchModal');
|
||||||
|
if (event.target === modal) {
|
||||||
|
closeAdvancedSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load users for dropdown
|
||||||
|
async function loadUsersForSearch() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/get_users.php');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.users) {
|
||||||
|
const createdBySelect = document.getElementById('adv-created-by');
|
||||||
|
const assignedToSelect = document.getElementById('adv-assigned-to');
|
||||||
|
|
||||||
|
// Clear existing options (except first default option)
|
||||||
|
while (createdBySelect.options.length > 1) {
|
||||||
|
createdBySelect.remove(1);
|
||||||
|
}
|
||||||
|
while (assignedToSelect.options.length > 2) { // Keep "Any" and "Unassigned"
|
||||||
|
assignedToSelect.remove(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add users to both dropdowns
|
||||||
|
data.users.forEach(user => {
|
||||||
|
const displayName = user.display_name || user.username;
|
||||||
|
|
||||||
|
const option1 = document.createElement('option');
|
||||||
|
option1.value = user.user_id;
|
||||||
|
option1.textContent = displayName;
|
||||||
|
createdBySelect.appendChild(option1);
|
||||||
|
|
||||||
|
const option2 = document.createElement('option');
|
||||||
|
option2.value = user.user_id;
|
||||||
|
option2.textContent = displayName;
|
||||||
|
assignedToSelect.appendChild(option2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading users:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate form with current URL parameters
|
||||||
|
function populateCurrentFilters() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
// Search text
|
||||||
|
if (urlParams.has('search')) {
|
||||||
|
document.getElementById('adv-search-text').value = urlParams.get('search');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status
|
||||||
|
if (urlParams.has('status')) {
|
||||||
|
const statuses = urlParams.get('status').split(',');
|
||||||
|
const statusSelect = document.getElementById('adv-status');
|
||||||
|
Array.from(statusSelect.options).forEach(option => {
|
||||||
|
option.selected = statuses.includes(option.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform advanced search
|
||||||
|
function performAdvancedSearch(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
// Search text
|
||||||
|
const searchText = document.getElementById('adv-search-text').value.trim();
|
||||||
|
if (searchText) {
|
||||||
|
params.set('search', searchText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date ranges
|
||||||
|
const createdFrom = document.getElementById('adv-created-from').value;
|
||||||
|
const createdTo = document.getElementById('adv-created-to').value;
|
||||||
|
const updatedFrom = document.getElementById('adv-updated-from').value;
|
||||||
|
const updatedTo = document.getElementById('adv-updated-to').value;
|
||||||
|
|
||||||
|
if (createdFrom) params.set('created_from', createdFrom);
|
||||||
|
if (createdTo) params.set('created_to', createdTo);
|
||||||
|
if (updatedFrom) params.set('updated_from', updatedFrom);
|
||||||
|
if (updatedTo) params.set('updated_to', updatedTo);
|
||||||
|
|
||||||
|
// Status (multi-select)
|
||||||
|
const statusSelect = document.getElementById('adv-status');
|
||||||
|
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
|
||||||
|
if (selectedStatuses.length > 0) {
|
||||||
|
params.set('status', selectedStatuses.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority range
|
||||||
|
const priorityMin = document.getElementById('adv-priority-min').value;
|
||||||
|
const priorityMax = document.getElementById('adv-priority-max').value;
|
||||||
|
if (priorityMin) params.set('priority_min', priorityMin);
|
||||||
|
if (priorityMax) params.set('priority_max', priorityMax);
|
||||||
|
|
||||||
|
// Users
|
||||||
|
const createdBy = document.getElementById('adv-created-by').value;
|
||||||
|
const assignedTo = document.getElementById('adv-assigned-to').value;
|
||||||
|
if (createdBy) params.set('created_by', createdBy);
|
||||||
|
if (assignedTo) params.set('assigned_to', assignedTo);
|
||||||
|
|
||||||
|
// Redirect to dashboard with params
|
||||||
|
window.location.href = '/?' + params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset advanced search form
|
||||||
|
function resetAdvancedSearch() {
|
||||||
|
document.getElementById('advancedSearchForm').reset();
|
||||||
|
|
||||||
|
// Unselect all multi-select options
|
||||||
|
const statusSelect = document.getElementById('adv-status');
|
||||||
|
Array.from(statusSelect.options).forEach(option => {
|
||||||
|
option.selected = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current search as a filter
|
||||||
|
async function saveCurrentFilter() {
|
||||||
|
showInputModal(
|
||||||
|
'Save Search Filter',
|
||||||
|
'Enter a name for this filter:',
|
||||||
|
'My Filter',
|
||||||
|
async (filterName) => {
|
||||||
|
if (!filterName || filterName.trim() === '') {
|
||||||
|
toast.warning('Filter name cannot be empty', 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterCriteria = getCurrentFilterCriteria();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/saved_filters.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.CSRF_TOKEN
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
filter_name: filterName.trim(),
|
||||||
|
filter_criteria: filterCriteria
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`Filter "${filterName}" saved successfully!`, 3000);
|
||||||
|
loadSavedFilters();
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'), 4000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving filter:', error);
|
||||||
|
toast.error('Error saving filter', 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current filter criteria from form
|
||||||
|
function getCurrentFilterCriteria() {
|
||||||
|
const criteria = {};
|
||||||
|
|
||||||
|
const searchText = document.getElementById('adv-search-text').value.trim();
|
||||||
|
if (searchText) criteria.search = searchText;
|
||||||
|
|
||||||
|
const createdFrom = document.getElementById('adv-created-from').value;
|
||||||
|
if (createdFrom) criteria.created_from = createdFrom;
|
||||||
|
|
||||||
|
const createdTo = document.getElementById('adv-created-to').value;
|
||||||
|
if (createdTo) criteria.created_to = createdTo;
|
||||||
|
|
||||||
|
const updatedFrom = document.getElementById('adv-updated-from').value;
|
||||||
|
if (updatedFrom) criteria.updated_from = updatedFrom;
|
||||||
|
|
||||||
|
const updatedTo = document.getElementById('adv-updated-to').value;
|
||||||
|
if (updatedTo) criteria.updated_to = updatedTo;
|
||||||
|
|
||||||
|
const statusSelect = document.getElementById('adv-status');
|
||||||
|
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
|
||||||
|
if (selectedStatuses.length > 0) criteria.status = selectedStatuses.join(',');
|
||||||
|
|
||||||
|
const priorityMin = document.getElementById('adv-priority-min').value;
|
||||||
|
if (priorityMin) criteria.priority_min = priorityMin;
|
||||||
|
|
||||||
|
const priorityMax = document.getElementById('adv-priority-max').value;
|
||||||
|
if (priorityMax) criteria.priority_max = priorityMax;
|
||||||
|
|
||||||
|
const createdBy = document.getElementById('adv-created-by').value;
|
||||||
|
if (createdBy) criteria.created_by = createdBy;
|
||||||
|
|
||||||
|
const assignedTo = document.getElementById('adv-assigned-to').value;
|
||||||
|
if (assignedTo) criteria.assigned_to = assignedTo;
|
||||||
|
|
||||||
|
return criteria;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved filters
|
||||||
|
async function loadSavedFilters() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/saved_filters.php');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.filters) {
|
||||||
|
populateSavedFiltersDropdown(data.filters);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading saved filters:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate saved filters dropdown
|
||||||
|
function populateSavedFiltersDropdown(filters) {
|
||||||
|
const dropdown = document.getElementById('saved-filters-select');
|
||||||
|
if (!dropdown) return;
|
||||||
|
|
||||||
|
// Clear existing options except the first (placeholder)
|
||||||
|
while (dropdown.options.length > 1) {
|
||||||
|
dropdown.remove(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add saved filters
|
||||||
|
filters.forEach(filter => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = filter.filter_id;
|
||||||
|
option.textContent = filter.filter_name + (filter.is_default ? ' ⭐' : '');
|
||||||
|
option.dataset.criteria = JSON.stringify(filter.filter_criteria);
|
||||||
|
dropdown.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load a saved filter
|
||||||
|
function loadSavedFilter() {
|
||||||
|
const dropdown = document.getElementById('saved-filters-select');
|
||||||
|
const selectedOption = dropdown.options[dropdown.selectedIndex];
|
||||||
|
|
||||||
|
if (!selectedOption || !selectedOption.dataset.criteria) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const criteria = JSON.parse(selectedOption.dataset.criteria);
|
||||||
|
applySavedFilterCriteria(criteria);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading filter:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply saved filter criteria to form
|
||||||
|
function applySavedFilterCriteria(criteria) {
|
||||||
|
// Search text
|
||||||
|
document.getElementById('adv-search-text').value = criteria.search || '';
|
||||||
|
|
||||||
|
// Date ranges
|
||||||
|
document.getElementById('adv-created-from').value = criteria.created_from || '';
|
||||||
|
document.getElementById('adv-created-to').value = criteria.created_to || '';
|
||||||
|
document.getElementById('adv-updated-from').value = criteria.updated_from || '';
|
||||||
|
document.getElementById('adv-updated-to').value = criteria.updated_to || '';
|
||||||
|
|
||||||
|
// Status
|
||||||
|
const statusSelect = document.getElementById('adv-status');
|
||||||
|
const statuses = criteria.status ? criteria.status.split(',') : [];
|
||||||
|
Array.from(statusSelect.options).forEach(option => {
|
||||||
|
option.selected = statuses.includes(option.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Priority
|
||||||
|
document.getElementById('adv-priority-min').value = criteria.priority_min || '';
|
||||||
|
document.getElementById('adv-priority-max').value = criteria.priority_max || '';
|
||||||
|
|
||||||
|
// Users
|
||||||
|
document.getElementById('adv-created-by').value = criteria.created_by || '';
|
||||||
|
document.getElementById('adv-assigned-to').value = criteria.assigned_to || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete saved filter
|
||||||
|
async function deleteSavedFilter() {
|
||||||
|
const dropdown = document.getElementById('saved-filters-select');
|
||||||
|
const selectedOption = dropdown.options[dropdown.selectedIndex];
|
||||||
|
|
||||||
|
if (!selectedOption || selectedOption.value === '') {
|
||||||
|
if (typeof toast !== 'undefined') {
|
||||||
|
toast.error('Please select a filter to delete');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterId = selectedOption.value;
|
||||||
|
const filterName = selectedOption.textContent;
|
||||||
|
|
||||||
|
showConfirmModal(
|
||||||
|
`Delete Filter "${filterName}"?`,
|
||||||
|
'This action cannot be undone.',
|
||||||
|
'error',
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/saved_filters.php', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.CSRF_TOKEN
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ filter_id: filterId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('Filter deleted successfully', 3000);
|
||||||
|
loadSavedFilters();
|
||||||
|
resetAdvancedSearch();
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to delete filter', 4000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting filter:', error);
|
||||||
|
toast.error('Error deleting filter', 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcut (Ctrl+Shift+F)
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
|
||||||
|
e.preventDefault();
|
||||||
|
openAdvancedSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC to close
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const modal = document.getElementById('advancedSearchModal');
|
||||||
|
if (modal && modal.style.display === 'flex') {
|
||||||
|
closeAdvancedSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
197
assets/js/ascii-banner.js
Normal file
197
assets/js/ascii-banner.js
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* ASCII Art Banners for Tinker Tickets - Terminal Edition
|
||||||
|
*
|
||||||
|
* This file contains ASCII art banners and rendering functions
|
||||||
|
* for the retro terminal aesthetic redesign.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ASCII Art Banner Definitions
|
||||||
|
const ASCII_BANNERS = {
|
||||||
|
// Main large banner for desktop
|
||||||
|
main: `
|
||||||
|
╔══════════════════════════════════════════════════════════════════════════╗
|
||||||
|
║ ║
|
||||||
|
║ ████████╗██╗███╗ ██╗██╗ ██╗███████╗██████╗ ║
|
||||||
|
║ ╚══██╔══╝██║████╗ ██║██║ ██╔╝██╔════╝██╔══██╗ ║
|
||||||
|
║ ██║ ██║██╔██╗ ██║█████╔╝ █████╗ ██████╔╝ ║
|
||||||
|
║ ██║ ██║██║╚██╗██║██╔═██╗ ██╔══╝ ██╔══██╗ ║
|
||||||
|
║ ██║ ██║██║ ╚████║██║ ██╗███████╗██║ ██║ ║
|
||||||
|
║ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ║
|
||||||
|
║ ║
|
||||||
|
║ ████████╗██╗ ██████╗██╗ ██╗███████╗████████╗███████╗ ║
|
||||||
|
║ ╚══██╔══╝██║██╔════╝██║ ██╔╝██╔════╝╚══██╔══╝██╔════╝ ║
|
||||||
|
║ ██║ ██║██║ █████╔╝ █████╗ ██║ ███████╗ ║
|
||||||
|
║ ██║ ██║██║ ██╔═██╗ ██╔══╝ ██║ ╚════██║ ║
|
||||||
|
║ ██║ ██║╚██████╗██║ ██╗███████╗ ██║ ███████║ ║
|
||||||
|
║ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚══════╝ ║
|
||||||
|
║ ║
|
||||||
|
║ >> RETRO TERMINAL TICKETING SYSTEM v1.0 << ║
|
||||||
|
║ ║
|
||||||
|
╚══════════════════════════════════════════════════════════════════════════╝
|
||||||
|
`,
|
||||||
|
|
||||||
|
// Compact version for smaller screens
|
||||||
|
compact: `
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ ▀█▀ █ █▄ █ █▄▀ █▀▀ █▀█ ▀█▀ █ █▀▀ █▄▀ █▀▀ ▀█▀ █▀ │
|
||||||
|
│ █ █ █ ▀█ █ █ ██▄ █▀▄ █ █ █▄▄ █ █ ██▄ █ ▄█ │
|
||||||
|
│ Terminal Ticketing System v1.0 │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
`,
|
||||||
|
|
||||||
|
// Minimal version for mobile
|
||||||
|
minimal: `
|
||||||
|
╔════════════════════════════╗
|
||||||
|
║ TINKER TICKETS v1.0 ║
|
||||||
|
╚════════════════════════════╝
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders ASCII banner with optional typewriter effect
|
||||||
|
*
|
||||||
|
* @param {string} bannerId - ID of banner to render ('main', 'compact', or 'minimal')
|
||||||
|
* @param {string} containerSelector - CSS selector for container element
|
||||||
|
* @param {number} speed - Speed of typewriter effect in milliseconds (0 = instant)
|
||||||
|
* @param {boolean} addGlow - Whether to add text glow effect
|
||||||
|
*/
|
||||||
|
function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = true) {
|
||||||
|
const banner = ASCII_BANNERS[bannerId];
|
||||||
|
const container = document.querySelector(containerSelector);
|
||||||
|
|
||||||
|
if (!container || !banner) {
|
||||||
|
console.error('ASCII Banner: Container or banner not found', { bannerId, containerSelector });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pre element for ASCII art
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
pre.className = 'ascii-banner';
|
||||||
|
pre.style.margin = '0';
|
||||||
|
pre.style.fontFamily = 'var(--font-mono)';
|
||||||
|
pre.style.color = 'var(--terminal-green)';
|
||||||
|
|
||||||
|
if (addGlow) {
|
||||||
|
pre.style.textShadow = 'var(--glow-green)';
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.style.fontSize = getBannerFontSize(bannerId);
|
||||||
|
pre.style.lineHeight = '1.2';
|
||||||
|
pre.style.whiteSpace = 'pre';
|
||||||
|
pre.style.overflow = 'visible';
|
||||||
|
pre.style.textAlign = 'center';
|
||||||
|
|
||||||
|
container.appendChild(pre);
|
||||||
|
|
||||||
|
// Instant render or typewriter effect
|
||||||
|
if (speed === 0) {
|
||||||
|
pre.textContent = banner;
|
||||||
|
} else {
|
||||||
|
renderWithTypewriter(pre, banner, speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get appropriate font size for banner type
|
||||||
|
*
|
||||||
|
* @param {string} bannerId - Banner ID
|
||||||
|
* @returns {string} - CSS font size
|
||||||
|
*/
|
||||||
|
function getBannerFontSize(bannerId) {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
|
||||||
|
if (bannerId === 'main') {
|
||||||
|
if (width < 768) return '0.4rem';
|
||||||
|
if (width < 1024) return '0.6rem';
|
||||||
|
return '0.8rem';
|
||||||
|
} else if (bannerId === 'compact') {
|
||||||
|
if (width < 768) return '0.6rem';
|
||||||
|
return '0.8rem';
|
||||||
|
} else {
|
||||||
|
return '0.8rem';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders text with typewriter effect
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Element to render into
|
||||||
|
* @param {string} text - Text to render
|
||||||
|
* @param {number} speed - Speed in milliseconds per character
|
||||||
|
*/
|
||||||
|
function renderWithTypewriter(element, text, speed) {
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const typeInterval = setInterval(() => {
|
||||||
|
element.textContent = text.substring(0, index);
|
||||||
|
index++;
|
||||||
|
|
||||||
|
if (index > text.length) {
|
||||||
|
clearInterval(typeInterval);
|
||||||
|
// Trigger completion event
|
||||||
|
const event = new CustomEvent('bannerComplete');
|
||||||
|
element.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
}, speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders responsive banner based on screen size
|
||||||
|
*
|
||||||
|
* @param {string} containerSelector - CSS selector for container
|
||||||
|
* @param {number} speed - Typewriter speed (0 = instant)
|
||||||
|
*/
|
||||||
|
function renderResponsiveBanner(containerSelector, speed = 5) {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
|
||||||
|
let bannerId;
|
||||||
|
if (width < 480) {
|
||||||
|
bannerId = 'minimal';
|
||||||
|
} else if (width < 1024) {
|
||||||
|
bannerId = 'compact';
|
||||||
|
} else {
|
||||||
|
bannerId = 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderASCIIBanner(bannerId, containerSelector, speed, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animated welcome sequence
|
||||||
|
* Shows banner followed by a blinking cursor effect
|
||||||
|
*
|
||||||
|
* @param {string} containerSelector - CSS selector for container
|
||||||
|
*/
|
||||||
|
function animatedWelcome(containerSelector) {
|
||||||
|
const container = document.querySelector(containerSelector);
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Clear container
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Render banner
|
||||||
|
renderResponsiveBanner(containerSelector, 3);
|
||||||
|
|
||||||
|
// Add blinking cursor after banner
|
||||||
|
const banner = container.querySelector('.ascii-banner');
|
||||||
|
if (banner) {
|
||||||
|
banner.addEventListener('bannerComplete', () => {
|
||||||
|
const cursor = document.createElement('span');
|
||||||
|
cursor.textContent = '█';
|
||||||
|
cursor.style.animation = 'blink-caret 0.75s step-end infinite';
|
||||||
|
cursor.style.marginLeft = '5px';
|
||||||
|
banner.appendChild(cursor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export functions for use in other scripts
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = {
|
||||||
|
ASCII_BANNERS,
|
||||||
|
renderASCIIBanner,
|
||||||
|
renderResponsiveBanner,
|
||||||
|
animatedWelcome
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
81
assets/js/keyboard-shortcuts.js
Normal file
81
assets/js/keyboard-shortcuts.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Keyboard shortcuts for power users
|
||||||
|
*/
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
// Skip if user is typing in an input/textarea
|
||||||
|
if (e.target.tagName === 'INPUT' ||
|
||||||
|
e.target.tagName === 'TEXTAREA' ||
|
||||||
|
e.target.isContentEditable) {
|
||||||
|
// Allow ESC to exit edit mode even when in input
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.target.blur();
|
||||||
|
const editButton = document.getElementById('editButton');
|
||||||
|
if (editButton && editButton.classList.contains('active')) {
|
||||||
|
editButton.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + E: Toggle edit mode (on ticket pages)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
|
||||||
|
e.preventDefault();
|
||||||
|
const editButton = document.getElementById('editButton');
|
||||||
|
if (editButton) {
|
||||||
|
editButton.click();
|
||||||
|
toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + S: Save ticket (on ticket pages)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
const editButton = document.getElementById('editButton');
|
||||||
|
if (editButton && editButton.classList.contains('active')) {
|
||||||
|
editButton.click();
|
||||||
|
toast.success('Saving ticket...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC: Cancel edit mode
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const editButton = document.getElementById('editButton');
|
||||||
|
if (editButton && editButton.classList.contains('active')) {
|
||||||
|
// Reset without saving
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + K: Focus search (on dashboard)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
const searchBox = document.querySelector('.search-box');
|
||||||
|
if (searchBox) {
|
||||||
|
searchBox.focus();
|
||||||
|
searchBox.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? : Show keyboard shortcuts help
|
||||||
|
if (e.key === '?' && !e.shiftKey) {
|
||||||
|
showKeyboardHelp();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function showKeyboardHelp() {
|
||||||
|
const helpText = `
|
||||||
|
╔════════════════════════════════════════╗
|
||||||
|
║ KEYBOARD SHORTCUTS ║
|
||||||
|
╠════════════════════════════════════════╣
|
||||||
|
║ Ctrl/Cmd + E : Toggle Edit Mode ║
|
||||||
|
║ Ctrl/Cmd + S : Save Changes ║
|
||||||
|
║ Ctrl/Cmd + K : Focus Search ║
|
||||||
|
║ ESC : Cancel Edit/Close ║
|
||||||
|
║ ? : Show This Help ║
|
||||||
|
╚════════════════════════════════════════╝
|
||||||
|
`;
|
||||||
|
toast.info(helpText, 5000);
|
||||||
|
}
|
||||||
77
assets/js/markdown.js
Normal file
77
assets/js/markdown.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Simple Markdown Parser for Tinker Tickets
|
||||||
|
* Supports basic markdown formatting without external dependencies
|
||||||
|
*/
|
||||||
|
|
||||||
|
function parseMarkdown(markdown) {
|
||||||
|
if (!markdown) return '';
|
||||||
|
|
||||||
|
let html = markdown;
|
||||||
|
|
||||||
|
// Escape HTML first to prevent XSS
|
||||||
|
html = html.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
|
// Code blocks (```code```)
|
||||||
|
html = html.replace(/```([\s\S]*?)```/g, '<pre class="code-block"><code>$1</code></pre>');
|
||||||
|
|
||||||
|
// Inline code (`code`)
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
||||||
|
|
||||||
|
// Bold (**text** or __text__)
|
||||||
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
// Italic (*text* or _text_)
|
||||||
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||||
|
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Links [text](url)
|
||||||
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||||
|
|
||||||
|
// Headers (# H1, ## H2, etc.)
|
||||||
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||||
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||||
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
// Unordered lists (- item or * item)
|
||||||
|
html = html.replace(/^\s*[-*]\s+(.+)$/gm, '<li>$1</li>');
|
||||||
|
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
||||||
|
|
||||||
|
// Ordered lists (1. item)
|
||||||
|
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
|
||||||
|
|
||||||
|
// Blockquotes (> text)
|
||||||
|
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
|
||||||
|
|
||||||
|
// Horizontal rules (--- or ***)
|
||||||
|
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>');
|
||||||
|
|
||||||
|
// Line breaks (two spaces at end of line or double newline)
|
||||||
|
html = html.replace(/ \n/g, '<br>');
|
||||||
|
html = html.replace(/\n\n/g, '</p><p>');
|
||||||
|
|
||||||
|
// Wrap in paragraph if not already wrapped
|
||||||
|
if (!html.startsWith('<')) {
|
||||||
|
html = '<p>' + html + '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply markdown rendering to all elements with data-markdown attribute
|
||||||
|
function renderMarkdownElements() {
|
||||||
|
document.querySelectorAll('[data-markdown]').forEach(element => {
|
||||||
|
const markdownText = element.getAttribute('data-markdown') || element.textContent;
|
||||||
|
element.innerHTML = parseMarkdown(markdownText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply markdown to description and comments on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', renderMarkdownElements);
|
||||||
|
|
||||||
|
// Expose for manual use
|
||||||
|
window.parseMarkdown = parseMarkdown;
|
||||||
|
window.renderMarkdownElements = renderMarkdownElements;
|
||||||
156
assets/js/settings.js
Normal file
156
assets/js/settings.js
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Settings Management System
|
||||||
|
* Handles loading, saving, and applying user preferences
|
||||||
|
*/
|
||||||
|
|
||||||
|
let userPreferences = {};
|
||||||
|
|
||||||
|
// Load preferences on page load
|
||||||
|
async function loadUserPreferences() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user_preferences.php');
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
userPreferences = data.preferences;
|
||||||
|
applyPreferences();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading preferences:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply preferences to UI
|
||||||
|
function applyPreferences() {
|
||||||
|
// Rows per page
|
||||||
|
const rowsPerPage = userPreferences.rows_per_page || '15';
|
||||||
|
const rowsSelect = document.getElementById('rowsPerPage');
|
||||||
|
if (rowsSelect) {
|
||||||
|
rowsSelect.value = rowsPerPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default filters
|
||||||
|
const defaultFilters = (userPreferences.default_status_filters || 'Open,Pending,In Progress').split(',');
|
||||||
|
document.querySelectorAll('[name="defaultFilters"]').forEach(cb => {
|
||||||
|
cb.checked = defaultFilters.includes(cb.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table density
|
||||||
|
const density = userPreferences.table_density || 'normal';
|
||||||
|
const densitySelect = document.getElementById('tableDensity');
|
||||||
|
if (densitySelect) {
|
||||||
|
densitySelect.value = density;
|
||||||
|
}
|
||||||
|
document.body.classList.remove('table-compact', 'table-comfortable');
|
||||||
|
if (density !== 'normal') {
|
||||||
|
document.body.classList.add(`table-${density}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
const notificationsCheckbox = document.getElementById('notificationsEnabled');
|
||||||
|
if (notificationsCheckbox) {
|
||||||
|
notificationsCheckbox.checked = userPreferences.notifications_enabled !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const soundCheckbox = document.getElementById('soundEffects');
|
||||||
|
if (soundCheckbox) {
|
||||||
|
soundCheckbox.checked = userPreferences.sound_effects !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toast duration
|
||||||
|
const toastDuration = userPreferences.toast_duration || '3000';
|
||||||
|
const toastSelect = document.getElementById('toastDuration');
|
||||||
|
if (toastSelect) {
|
||||||
|
toastSelect.value = toastDuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save preferences
|
||||||
|
async function saveSettings() {
|
||||||
|
const prefs = {
|
||||||
|
rows_per_page: document.getElementById('rowsPerPage').value,
|
||||||
|
default_status_filters: Array.from(document.querySelectorAll('[name="defaultFilters"]:checked'))
|
||||||
|
.map(cb => cb.value).join(','),
|
||||||
|
table_density: document.getElementById('tableDensity').value,
|
||||||
|
notifications_enabled: document.getElementById('notificationsEnabled').checked ? '1' : '0',
|
||||||
|
sound_effects: document.getElementById('soundEffects').checked ? '1' : '0',
|
||||||
|
toast_duration: document.getElementById('toastDuration').value
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save each preference
|
||||||
|
for (const [key, value] of Object.entries(prefs)) {
|
||||||
|
const response = await fetch('/api/user_preferences.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.CSRF_TOKEN
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key, value })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(`Failed to save ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof toast !== 'undefined') {
|
||||||
|
toast.success('Preferences saved successfully!');
|
||||||
|
}
|
||||||
|
closeSettingsModal();
|
||||||
|
|
||||||
|
// Reload page to apply new preferences
|
||||||
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof toast !== 'undefined') {
|
||||||
|
toast.error('Error saving preferences');
|
||||||
|
}
|
||||||
|
console.error('Error saving preferences:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal controls
|
||||||
|
function openSettingsModal() {
|
||||||
|
const modal = document.getElementById('settingsModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
loadUserPreferences();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettingsModal() {
|
||||||
|
const modal = document.getElementById('settingsModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking on backdrop (outside the settings content)
|
||||||
|
function closeOnBackdropClick(event) {
|
||||||
|
const modal = document.getElementById('settingsModal');
|
||||||
|
// Only close if clicking directly on the modal backdrop, not on content
|
||||||
|
if (event.target === modal) {
|
||||||
|
closeSettingsModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcut to open settings (Alt+S)
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.altKey && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
openSettingsModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC to close modal
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const modal = document.getElementById('settingsModal');
|
||||||
|
if (modal && modal.style.display === 'block') {
|
||||||
|
closeSettingsModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', loadUserPreferences);
|
||||||
@@ -18,8 +18,13 @@ function saveTicket() {
|
|||||||
|
|
||||||
editables.forEach(field => {
|
editables.forEach(field => {
|
||||||
if (field.dataset.field) {
|
if (field.dataset.field) {
|
||||||
|
// For contenteditable divs, use textContent/innerText; for inputs/textareas, use value
|
||||||
|
if (field.hasAttribute('contenteditable')) {
|
||||||
|
data[field.dataset.field] = field.textContent.trim();
|
||||||
|
} else {
|
||||||
data[field.dataset.field] = field.value;
|
data[field.dataset.field] = field.value;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use the correct API path
|
// Use the correct API path
|
||||||
@@ -28,7 +33,8 @@ function saveTicket() {
|
|||||||
fetch(apiUrl, {
|
fetch(apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.CSRF_TOKEN
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
ticket_id: ticketId,
|
ticket_id: ticketId,
|
||||||
@@ -51,7 +57,7 @@ function saveTicket() {
|
|||||||
statusDisplay.className = `status-${data.status}`;
|
statusDisplay.className = `status-${data.status}`;
|
||||||
statusDisplay.textContent = data.status;
|
statusDisplay.textContent = data.status;
|
||||||
}
|
}
|
||||||
console.log('Ticket updated successfully');
|
toast.success('Ticket updated successfully');
|
||||||
} else {
|
} else {
|
||||||
console.error('Error in API response:', data.error || 'Unknown error');
|
console.error('Error in API response:', data.error || 'Unknown error');
|
||||||
}
|
}
|
||||||
@@ -63,23 +69,47 @@ function saveTicket() {
|
|||||||
|
|
||||||
function toggleEditMode() {
|
function toggleEditMode() {
|
||||||
const editButton = document.getElementById('editButton');
|
const editButton = document.getElementById('editButton');
|
||||||
const editables = document.querySelectorAll('.title-input, textarea[data-field="description"]');
|
const titleField = document.querySelector('.title-input');
|
||||||
|
const descriptionField = document.querySelector('textarea[data-field="description"]');
|
||||||
|
const metadataFields = document.querySelectorAll('.editable-metadata');
|
||||||
const isEditing = editButton.classList.contains('active');
|
const isEditing = editButton.classList.contains('active');
|
||||||
|
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
editButton.textContent = 'Save Changes';
|
editButton.textContent = 'Save Changes';
|
||||||
editButton.classList.add('active');
|
editButton.classList.add('active');
|
||||||
editables.forEach(field => {
|
|
||||||
field.disabled = false;
|
// Enable title (contenteditable div)
|
||||||
if (field.classList.contains('title-input')) {
|
if (titleField) {
|
||||||
field.focus();
|
titleField.setAttribute('contenteditable', 'true');
|
||||||
|
titleField.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable description (textarea)
|
||||||
|
if (descriptionField) {
|
||||||
|
descriptionField.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable metadata fields (priority, category, type)
|
||||||
|
metadataFields.forEach(field => {
|
||||||
|
field.disabled = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
saveTicket();
|
saveTicket();
|
||||||
editButton.textContent = 'Edit Ticket';
|
editButton.textContent = 'Edit Ticket';
|
||||||
editButton.classList.remove('active');
|
editButton.classList.remove('active');
|
||||||
editables.forEach(field => {
|
|
||||||
|
// Disable title
|
||||||
|
if (titleField) {
|
||||||
|
titleField.setAttribute('contenteditable', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable description
|
||||||
|
if (descriptionField) {
|
||||||
|
descriptionField.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable metadata fields
|
||||||
|
metadataFields.forEach(field => {
|
||||||
field.disabled = true;
|
field.disabled = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -111,7 +141,8 @@ function addComment() {
|
|||||||
fetch('/api/add_comment.php', {
|
fetch('/api/add_comment.php', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.CSRF_TOKEN
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
ticket_id: ticketId,
|
ticket_id: ticketId,
|
||||||
@@ -149,18 +180,33 @@ function addComment() {
|
|||||||
.replace(/\n/g, '<br>');
|
.replace(/\n/g, '<br>');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new comment to the list
|
// Add new comment to the list (using safe DOM API to prevent XSS)
|
||||||
const commentsList = document.querySelector('.comments-list');
|
const commentsList = document.querySelector('.comments-list');
|
||||||
const newComment = `
|
|
||||||
<div class="comment">
|
const commentDiv = document.createElement('div');
|
||||||
<div class="comment-header">
|
commentDiv.className = 'comment';
|
||||||
<span class="comment-user">${data.user_name}</span>
|
|
||||||
<span class="comment-date">${data.created_at}</span>
|
const headerDiv = document.createElement('div');
|
||||||
</div>
|
headerDiv.className = 'comment-header';
|
||||||
<div class="comment-text">${displayText}</div>
|
|
||||||
</div>
|
const userSpan = document.createElement('span');
|
||||||
`;
|
userSpan.className = 'comment-user';
|
||||||
commentsList.insertAdjacentHTML('afterbegin', newComment);
|
userSpan.textContent = data.user_name; // Safe - auto-escapes
|
||||||
|
|
||||||
|
const dateSpan = document.createElement('span');
|
||||||
|
dateSpan.className = 'comment-date';
|
||||||
|
dateSpan.textContent = data.created_at; // Safe - auto-escapes
|
||||||
|
|
||||||
|
const textDiv = document.createElement('div');
|
||||||
|
textDiv.className = 'comment-text';
|
||||||
|
textDiv.innerHTML = displayText; // displayText already sanitized above
|
||||||
|
|
||||||
|
headerDiv.appendChild(userSpan);
|
||||||
|
headerDiv.appendChild(dateSpan);
|
||||||
|
commentDiv.appendChild(headerDiv);
|
||||||
|
commentDiv.appendChild(textDiv);
|
||||||
|
|
||||||
|
commentsList.insertBefore(commentDiv, commentsList.firstChild);
|
||||||
} else {
|
} else {
|
||||||
console.error('Error adding comment:', data.error || 'Unknown error');
|
console.error('Error adding comment:', data.error || 'Unknown error');
|
||||||
}
|
}
|
||||||
@@ -214,37 +260,280 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Show description tab by default
|
// Show description tab by default
|
||||||
showTab('description');
|
showTab('description');
|
||||||
|
|
||||||
|
// Auto-resize function for textareas
|
||||||
|
function autoResizeTextarea(textarea) {
|
||||||
|
// Reset height to auto to get the correct scrollHeight
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
// Set the height to match the scrollHeight
|
||||||
|
textarea.style.height = textarea.scrollHeight + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-resize the description textarea to fit content
|
// Auto-resize the description textarea to fit content
|
||||||
const descriptionTextarea = document.querySelector('textarea[data-field="description"]');
|
const descriptionTextarea = document.querySelector('textarea[data-field="description"]');
|
||||||
if (descriptionTextarea) {
|
if (descriptionTextarea) {
|
||||||
function autoResizeTextarea() {
|
|
||||||
// Reset height to auto to get the correct scrollHeight
|
|
||||||
descriptionTextarea.style.height = 'auto';
|
|
||||||
// Set the height to match the scrollHeight
|
|
||||||
descriptionTextarea.style.height = descriptionTextarea.scrollHeight + 'px';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial resize
|
// Initial resize
|
||||||
autoResizeTextarea();
|
autoResizeTextarea(descriptionTextarea);
|
||||||
|
|
||||||
// Resize on input when in edit mode
|
// Resize on input when in edit mode
|
||||||
descriptionTextarea.addEventListener('input', autoResizeTextarea);
|
descriptionTextarea.addEventListener('input', function() {
|
||||||
}
|
autoResizeTextarea(this);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize assignment handling
|
||||||
|
handleAssignmentChange();
|
||||||
|
|
||||||
|
// Initialize metadata field handlers (priority, category, type)
|
||||||
|
handleMetadataChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle ticket assignment dropdown changes
|
||||||
|
*/
|
||||||
|
function handleAssignmentChange() {
|
||||||
|
const assignedToSelect = document.getElementById('assignedToSelect');
|
||||||
|
if (!assignedToSelect) return;
|
||||||
|
|
||||||
|
assignedToSelect.addEventListener('change', function() {
|
||||||
|
const ticketId = window.ticketData.id;
|
||||||
|
const assignedTo = this.value || null;
|
||||||
|
|
||||||
|
fetch('/api/assign_ticket.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.CSRF_TOKEN
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ticket_id: ticketId, assigned_to: assignedTo })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success) {
|
||||||
|
toast.error('Error updating assignment');
|
||||||
|
console.error(data.error);
|
||||||
|
} else {
|
||||||
|
console.log('Assignment updated successfully');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error updating assignment:', error);
|
||||||
|
toast.error('Error updating assignment: ' + error.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle metadata field changes (priority, category, type)
|
||||||
|
*/
|
||||||
|
function handleMetadataChanges() {
|
||||||
|
const prioritySelect = document.getElementById('prioritySelect');
|
||||||
|
const categorySelect = document.getElementById('categorySelect');
|
||||||
|
const typeSelect = document.getElementById('typeSelect');
|
||||||
|
|
||||||
|
// Helper function to update ticket field
|
||||||
|
function updateTicketField(fieldName, newValue) {
|
||||||
|
const ticketId = window.ticketData.id;
|
||||||
|
|
||||||
|
fetch('/api/update_ticket.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.CSRF_TOKEN
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
ticket_id: ticketId,
|
||||||
|
[fieldName]: fieldName === 'priority' ? parseInt(newValue) : newValue
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success) {
|
||||||
|
toast.error(`Error updating ${fieldName}`);
|
||||||
|
console.error(data.error);
|
||||||
|
} else {
|
||||||
|
console.log(`${fieldName} updated successfully to:`, newValue);
|
||||||
|
|
||||||
|
// Update window.ticketData
|
||||||
|
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
|
||||||
|
|
||||||
|
// For priority, also update the priority indicator if it exists
|
||||||
|
if (fieldName === 'priority') {
|
||||||
|
const priorityIndicator = document.querySelector('.priority-indicator');
|
||||||
|
if (priorityIndicator) {
|
||||||
|
priorityIndicator.className = `priority-indicator priority-${newValue}`;
|
||||||
|
priorityIndicator.textContent = 'P' + newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ticket container priority attribute
|
||||||
|
const ticketContainer = document.querySelector('.ticket-container');
|
||||||
|
if (ticketContainer) {
|
||||||
|
ticketContainer.setAttribute('data-priority', newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(`Error updating ${fieldName}:`, error);
|
||||||
|
toast.error(`Error updating ${fieldName}: ` + error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority change handler
|
||||||
|
if (prioritySelect) {
|
||||||
|
prioritySelect.addEventListener('change', function() {
|
||||||
|
updateTicketField('priority', this.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category change handler
|
||||||
|
if (categorySelect) {
|
||||||
|
categorySelect.addEventListener('change', function() {
|
||||||
|
updateTicketField('category', this.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type change handler
|
||||||
|
if (typeSelect) {
|
||||||
|
typeSelect.addEventListener('change', function() {
|
||||||
|
updateTicketField('type', this.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTicketStatus() {
|
||||||
|
const statusSelect = document.getElementById('statusSelect');
|
||||||
|
const selectedOption = statusSelect.options[statusSelect.selectedIndex];
|
||||||
|
const newStatus = selectedOption.value;
|
||||||
|
const requiresComment = selectedOption.dataset.requiresComment === '1';
|
||||||
|
const requiresAdmin = selectedOption.dataset.requiresAdmin === '1';
|
||||||
|
|
||||||
|
// Check if transitioning to the same status (current)
|
||||||
|
if (selectedOption.text.includes('(current)')) {
|
||||||
|
return; // No change needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn if comment is required
|
||||||
|
if (requiresComment) {
|
||||||
|
showConfirmModal(
|
||||||
|
'Status Change Requires Comment',
|
||||||
|
`This transition to "${newStatus}" requires a comment explaining the reason.\n\nPlease add a comment before changing the status.`,
|
||||||
|
'warning',
|
||||||
|
() => {
|
||||||
|
// User confirmed, proceed with status change
|
||||||
|
performStatusChange(statusSelect, newStatus);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// User cancelled, reset to current status
|
||||||
|
statusSelect.selectedIndex = 0;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
performStatusChange(statusSelect, newStatus);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract status change logic into reusable function
|
||||||
|
function performStatusChange(statusSelect, newStatus) {
|
||||||
|
// Extract ticket ID
|
||||||
|
let ticketId;
|
||||||
|
if (window.location.href.includes('?id=')) {
|
||||||
|
ticketId = window.location.href.split('id=')[1];
|
||||||
|
} else {
|
||||||
|
const matches = window.location.pathname.match(/\/ticket\/(\d+)/);
|
||||||
|
ticketId = matches ? matches[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ticketId) {
|
||||||
|
console.error('Could not determine ticket ID');
|
||||||
|
statusSelect.selectedIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status via API
|
||||||
|
fetch('/api/update_ticket.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': window.CSRF_TOKEN
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
ticket_id: ticketId,
|
||||||
|
status: newStatus
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(async response => {
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Server error response:', text);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
throw new Error(data.error || 'Server returned an error');
|
||||||
|
} catch (parseError) {
|
||||||
|
throw new Error(text || 'Network response was not ok');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Failed to parse JSON:', text);
|
||||||
|
throw new Error('Invalid JSON response from server');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Update the dropdown to show new status as current
|
||||||
|
const newClass = 'status-' + newStatus.toLowerCase().replace(/ /g, '-');
|
||||||
|
statusSelect.className = 'editable status-select ' + newClass;
|
||||||
|
|
||||||
|
// Update the selected option text to show as current
|
||||||
|
selectedOption.text = newStatus + ' (current)';
|
||||||
|
|
||||||
|
// Move the selected option to the top
|
||||||
|
statusSelect.remove(statusSelect.selectedIndex);
|
||||||
|
statusSelect.insertBefore(selectedOption, statusSelect.firstChild);
|
||||||
|
statusSelect.selectedIndex = 0;
|
||||||
|
|
||||||
|
console.log('Status updated successfully to:', newStatus);
|
||||||
|
|
||||||
|
// Reload page to refresh activity timeline
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
console.error('Error updating status:', data.error || 'Unknown error');
|
||||||
|
toast.error('Error updating status: ' + (data.error || 'Unknown error'));
|
||||||
|
// Reset to current status
|
||||||
|
statusSelect.selectedIndex = 0;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error updating status:', error);
|
||||||
|
toast.error('Error updating status: ' + error.message);
|
||||||
|
// Reset to current status
|
||||||
|
statusSelect.selectedIndex = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function showTab(tabName) {
|
function showTab(tabName) {
|
||||||
// Hide all tab contents
|
// Hide all tab contents
|
||||||
const descriptionTab = document.getElementById('description-tab');
|
const descriptionTab = document.getElementById('description-tab');
|
||||||
const commentsTab = document.getElementById('comments-tab');
|
const commentsTab = document.getElementById('comments-tab');
|
||||||
|
const activityTab = document.getElementById('activity-tab');
|
||||||
|
|
||||||
if (!descriptionTab || !commentsTab) {
|
if (!descriptionTab || !commentsTab) {
|
||||||
console.error('Tab elements not found');
|
console.error('Tab elements not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide both tabs
|
// Hide all tabs
|
||||||
descriptionTab.style.display = 'none';
|
descriptionTab.style.display = 'none';
|
||||||
commentsTab.style.display = 'none';
|
commentsTab.style.display = 'none';
|
||||||
|
if (activityTab) {
|
||||||
|
activityTab.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Remove active class from all buttons
|
// Remove active class from all buttons
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
|||||||
83
assets/js/toast.js
Normal file
83
assets/js/toast.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Terminal-style toast notification system with queuing
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Toast queue management
|
||||||
|
let toastQueue = [];
|
||||||
|
let currentToast = null;
|
||||||
|
|
||||||
|
function showToast(message, type = 'info', duration = 3000) {
|
||||||
|
// Queue if a toast is already showing
|
||||||
|
if (currentToast) {
|
||||||
|
toastQueue.push({ message, type, duration });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayToast(message, type, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayToast(message, type, duration) {
|
||||||
|
// Create toast element
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `terminal-toast toast-${type}`;
|
||||||
|
currentToast = toast;
|
||||||
|
|
||||||
|
// Icon based on type
|
||||||
|
const icons = {
|
||||||
|
success: '✓',
|
||||||
|
error: '✗',
|
||||||
|
info: 'ℹ',
|
||||||
|
warning: '⚠'
|
||||||
|
};
|
||||||
|
|
||||||
|
toast.innerHTML = `
|
||||||
|
<span class="toast-icon">[${icons[type] || 'ℹ'}]</span>
|
||||||
|
<span class="toast-message">${message}</span>
|
||||||
|
<span class="toast-close" style="margin-left: auto; cursor: pointer; opacity: 0.7; padding-left: 1rem;">[×]</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to document
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
// Trigger animation
|
||||||
|
setTimeout(() => toast.classList.add('show'), 10);
|
||||||
|
|
||||||
|
// Manual dismiss handler
|
||||||
|
const closeBtn = toast.querySelector('.toast-close');
|
||||||
|
closeBtn.addEventListener('click', () => dismissToast(toast));
|
||||||
|
|
||||||
|
// Auto-remove after duration
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
dismissToast(toast);
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
// Store timeout ID for manual dismiss
|
||||||
|
toast.timeoutId = timeoutId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissToast(toast) {
|
||||||
|
// Clear auto-dismiss timeout
|
||||||
|
if (toast.timeoutId) {
|
||||||
|
clearTimeout(toast.timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.classList.remove('show');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
currentToast = null;
|
||||||
|
|
||||||
|
// Show next toast in queue
|
||||||
|
if (toastQueue.length > 0) {
|
||||||
|
const next = toastQueue.shift();
|
||||||
|
displayToast(next.message, next.type, next.duration);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience functions
|
||||||
|
window.toast = {
|
||||||
|
success: (msg, duration) => showToast(msg, 'success', duration),
|
||||||
|
error: (msg, duration) => showToast(msg, 'error', duration),
|
||||||
|
info: (msg, duration) => showToast(msg, 'info', duration),
|
||||||
|
warning: (msg, duration) => showToast(msg, 'warning', duration)
|
||||||
|
};
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
<?php
|
<?php
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
$envFile = __DIR__ . '/../.env';
|
$envFile = __DIR__ . '/../.env';
|
||||||
$envVars = parse_ini_file($envFile);
|
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
||||||
|
|
||||||
|
// Strip quotes from values if present (parse_ini_file may include them)
|
||||||
|
if ($envVars) {
|
||||||
|
foreach ($envVars as $key => $value) {
|
||||||
|
if (is_string($value)) {
|
||||||
|
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||||
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
||||||
|
$envVars[$key] = substr($value, 1, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Global configuration
|
// Global configuration
|
||||||
$GLOBALS['config'] = [
|
$GLOBALS['config'] = [
|
||||||
|
|||||||
@@ -1,37 +1,67 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once 'models/TicketModel.php';
|
require_once 'models/TicketModel.php';
|
||||||
|
require_once 'models/UserPreferencesModel.php';
|
||||||
|
|
||||||
class DashboardController {
|
class DashboardController {
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
|
private $prefsModel;
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn) {
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
|
$this->prefsModel = new UserPreferencesModel($conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index() {
|
public function index() {
|
||||||
|
// Get user ID for preferences
|
||||||
|
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
|
||||||
|
|
||||||
// Get query parameters
|
// Get query parameters
|
||||||
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
||||||
$limit = isset($_COOKIE['ticketsPerPage']) ? (int)$_COOKIE['ticketsPerPage'] : 15;
|
|
||||||
|
// Get rows per page from user preferences, fallback to cookie, then default
|
||||||
|
$limit = 15;
|
||||||
|
if ($userId) {
|
||||||
|
$limit = (int)$this->prefsModel->getPreference($userId, 'rows_per_page', 15);
|
||||||
|
} else if (isset($_COOKIE['ticketsPerPage'])) {
|
||||||
|
$limit = (int)$_COOKIE['ticketsPerPage'];
|
||||||
|
}
|
||||||
|
|
||||||
$sortColumn = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
|
$sortColumn = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
|
||||||
$sortDirection = isset($_GET['dir']) ? $_GET['dir'] : 'desc';
|
$sortDirection = isset($_GET['dir']) ? $_GET['dir'] : 'desc';
|
||||||
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
||||||
$type = isset($_GET['type']) ? $_GET['type'] : null;
|
$type = isset($_GET['type']) ? $_GET['type'] : null;
|
||||||
$search = isset($_GET['search']) ? trim($_GET['search']) : null; // ADD THIS LINE
|
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
|
||||||
|
|
||||||
// Handle status filtering
|
// Handle status filtering with user preferences
|
||||||
$status = null;
|
$status = null;
|
||||||
if (isset($_GET['status']) && !empty($_GET['status'])) {
|
if (isset($_GET['status']) && !empty($_GET['status'])) {
|
||||||
$status = $_GET['status'];
|
$status = $_GET['status'];
|
||||||
} else if (!isset($_GET['show_all'])) {
|
} else if (!isset($_GET['show_all'])) {
|
||||||
// Default: show Open and In Progress (exclude Closed)
|
// Get default status filters from user preferences
|
||||||
$status = 'Open,In Progress';
|
if ($userId) {
|
||||||
|
$status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress');
|
||||||
|
} else {
|
||||||
|
// Default: show Open, Pending, and In Progress (exclude Closed)
|
||||||
|
$status = 'Open,Pending,In Progress';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null)
|
// If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null)
|
||||||
|
|
||||||
// Get tickets with pagination, sorting, and search
|
// Build advanced search filters array
|
||||||
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search);
|
$filters = [];
|
||||||
|
if (isset($_GET['created_from'])) $filters['created_from'] = $_GET['created_from'];
|
||||||
|
if (isset($_GET['created_to'])) $filters['created_to'] = $_GET['created_to'];
|
||||||
|
if (isset($_GET['updated_from'])) $filters['updated_from'] = $_GET['updated_from'];
|
||||||
|
if (isset($_GET['updated_to'])) $filters['updated_to'] = $_GET['updated_to'];
|
||||||
|
if (isset($_GET['priority_min'])) $filters['priority_min'] = $_GET['priority_min'];
|
||||||
|
if (isset($_GET['priority_max'])) $filters['priority_max'] = $_GET['priority_max'];
|
||||||
|
if (isset($_GET['created_by'])) $filters['created_by'] = $_GET['created_by'];
|
||||||
|
if (isset($_GET['assigned_to'])) $filters['assigned_to'] = $_GET['assigned_to'];
|
||||||
|
|
||||||
|
// Get tickets with pagination, sorting, search, and advanced filters
|
||||||
|
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters);
|
||||||
|
|
||||||
// Get categories and types for filters
|
// Get categories and types for filters
|
||||||
$categories = $this->getCategories();
|
$categories = $this->getCategories();
|
||||||
|
|||||||
@@ -2,15 +2,27 @@
|
|||||||
// Use absolute paths for model includes
|
// Use absolute paths for model includes
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||||
|
|
||||||
class TicketController {
|
class TicketController {
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
private $commentModel;
|
private $commentModel;
|
||||||
|
private $auditLogModel;
|
||||||
|
private $userModel;
|
||||||
|
private $workflowModel;
|
||||||
|
private $templateModel;
|
||||||
private $envVars;
|
private $envVars;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn) {
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
|
$this->auditLogModel = new AuditLogModel($conn);
|
||||||
|
$this->userModel = new UserModel($conn);
|
||||||
|
$this->workflowModel = new WorkflowModel($conn);
|
||||||
|
$this->templateModel = new TemplateModel($conn);
|
||||||
|
|
||||||
// Load environment variables for Discord webhook
|
// Load environment variables for Discord webhook
|
||||||
$envPath = dirname(__DIR__) . '/.env';
|
$envPath = dirname(__DIR__) . '/.env';
|
||||||
@@ -20,13 +32,24 @@ class TicketController {
|
|||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
|
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
|
||||||
list($key, $value) = explode('=', $line, 2);
|
list($key, $value) = explode('=', $line, 2);
|
||||||
$this->envVars[trim($key)] = trim($value);
|
$key = trim($key);
|
||||||
|
$value = trim($value);
|
||||||
|
// Remove surrounding quotes if present
|
||||||
|
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||||
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
||||||
|
$value = substr($value, 1, -1);
|
||||||
|
}
|
||||||
|
$this->envVars[$key] = $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view($id) {
|
public function view($id) {
|
||||||
|
// Get current user
|
||||||
|
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||||
|
$userId = $currentUser['user_id'] ?? null;
|
||||||
|
|
||||||
// Get ticket data
|
// Get ticket data
|
||||||
$ticket = $this->ticketModel->getTicketById($id);
|
$ticket = $this->ticketModel->getTicketById($id);
|
||||||
|
|
||||||
@@ -39,11 +62,24 @@ class TicketController {
|
|||||||
// Get comments for this ticket using CommentModel
|
// Get comments for this ticket using CommentModel
|
||||||
$comments = $this->commentModel->getCommentsByTicketId($id);
|
$comments = $this->commentModel->getCommentsByTicketId($id);
|
||||||
|
|
||||||
|
// Get timeline for this ticket
|
||||||
|
$timeline = $this->auditLogModel->getTicketTimeline($id);
|
||||||
|
|
||||||
|
// Get all users for assignment dropdown
|
||||||
|
$allUsers = $this->userModel->getAllUsers();
|
||||||
|
|
||||||
|
// Get allowed status transitions for this ticket
|
||||||
|
$allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']);
|
||||||
|
|
||||||
// Load the view
|
// Load the view
|
||||||
include dirname(__DIR__) . '/views/TicketView.php';
|
include dirname(__DIR__) . '/views/TicketView.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create() {
|
public function create() {
|
||||||
|
// Get current user
|
||||||
|
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||||
|
$userId = $currentUser['user_id'] ?? null;
|
||||||
|
|
||||||
// Check if form was submitted
|
// Check if form was submitted
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$ticketData = [
|
$ticketData = [
|
||||||
@@ -57,14 +93,20 @@ class TicketController {
|
|||||||
// Validate input
|
// Validate input
|
||||||
if (empty($ticketData['title'])) {
|
if (empty($ticketData['title'])) {
|
||||||
$error = "Title is required";
|
$error = "Title is required";
|
||||||
|
$templates = $this->templateModel->getAllTemplates();
|
||||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ticket
|
// Create ticket with user tracking
|
||||||
$result = $this->ticketModel->createTicket($ticketData);
|
$result = $this->ticketModel->createTicket($ticketData, $userId);
|
||||||
|
|
||||||
if ($result['success']) {
|
if ($result['success']) {
|
||||||
|
// Log ticket creation to audit log
|
||||||
|
if (isset($GLOBALS['auditLog']) && $userId) {
|
||||||
|
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
|
||||||
|
}
|
||||||
|
|
||||||
// Send Discord webhook notification for new ticket
|
// Send Discord webhook notification for new ticket
|
||||||
$this->sendDiscordWebhook($result['ticket_id'], $ticketData);
|
$this->sendDiscordWebhook($result['ticket_id'], $ticketData);
|
||||||
|
|
||||||
@@ -73,16 +115,24 @@ class TicketController {
|
|||||||
exit;
|
exit;
|
||||||
} else {
|
} else {
|
||||||
$error = $result['error'];
|
$error = $result['error'];
|
||||||
|
$templates = $this->templateModel->getAllTemplates();
|
||||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Get all templates for the template selector
|
||||||
|
$templates = $this->templateModel->getAllTemplates();
|
||||||
|
|
||||||
// Display the create ticket form
|
// Display the create ticket form
|
||||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update($id) {
|
public function update($id) {
|
||||||
|
// Get current user
|
||||||
|
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||||
|
$userId = $currentUser['user_id'] ?? null;
|
||||||
|
|
||||||
// Check if this is an AJAX request
|
// Check if this is an AJAX request
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
// For AJAX requests, get JSON data
|
// For AJAX requests, get JSON data
|
||||||
@@ -102,8 +152,13 @@ class TicketController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update ticket
|
// Update ticket with user tracking
|
||||||
$result = $this->ticketModel->updateTicket($data);
|
$result = $this->ticketModel->updateTicket($data, $userId);
|
||||||
|
|
||||||
|
// Log ticket update to audit log
|
||||||
|
if ($result && isset($GLOBALS['auditLog']) && $userId) {
|
||||||
|
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
|
||||||
|
}
|
||||||
|
|
||||||
// Return JSON response
|
// Return JSON response
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -134,7 +189,7 @@ class TicketController {
|
|||||||
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
|
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
|
||||||
|
|
||||||
// Create ticket URL
|
// Create ticket URL
|
||||||
$ticketUrl = "http://tinkertickets.local/ticket/$ticketId";
|
$ticketUrl = "http://t.lotusguild.org/ticket/$ticketId";
|
||||||
|
|
||||||
// Map priorities to Discord colors
|
// Map priorities to Discord colors
|
||||||
$priorityColors = [
|
$priorityColors = [
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ error_reporting(E_ALL);
|
|||||||
ini_set('display_errors', 1);
|
ini_set('display_errors', 1);
|
||||||
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
|
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
|
||||||
|
|
||||||
|
|
||||||
// Load environment variables with error check
|
// Load environment variables with error check
|
||||||
$envFile = __DIR__ . '/.env';
|
$envFile = __DIR__ . '/.env';
|
||||||
if (!file_exists($envFile)) {
|
if (!file_exists($envFile)) {
|
||||||
@@ -16,7 +15,7 @@ if (!file_exists($envFile)) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$envVars = parse_ini_file($envFile);
|
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
||||||
if (!$envVars) {
|
if (!$envVars) {
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
@@ -25,6 +24,16 @@ if (!$envVars) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip quotes from values if present (parse_ini_file may include them)
|
||||||
|
foreach ($envVars as $key => $value) {
|
||||||
|
if (is_string($value)) {
|
||||||
|
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||||
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
||||||
|
$envVars[$key] = substr($value, 1, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Database connection with detailed error handling
|
// Database connection with detailed error handling
|
||||||
$conn = new mysqli(
|
$conn = new mysqli(
|
||||||
$envVars['DB_HOST'],
|
$envVars['DB_HOST'],
|
||||||
@@ -41,6 +50,22 @@ if ($conn->connect_error) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authenticate via API key
|
||||||
|
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
||||||
|
require_once __DIR__ . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
$apiKeyAuth = new ApiKeyAuth($conn);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$systemUser = $apiKeyAuth->authenticate();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Authentication failed - ApiKeyAuth already sent the response
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $systemUser['user_id'];
|
||||||
|
file_put_contents('debug.log', "Authenticated as system user ID: $userId\n", FILE_APPEND);
|
||||||
|
|
||||||
// Create tickets table with hash column if not exists
|
// Create tickets table with hash column if not exists
|
||||||
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
|
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
@@ -59,22 +84,32 @@ $data = json_decode($rawInput, true);
|
|||||||
|
|
||||||
// Generate hash from stable components
|
// Generate hash from stable components
|
||||||
function generateTicketHash($data) {
|
function generateTicketHash($data) {
|
||||||
// Extract device name if present (matches /dev/sdX pattern)
|
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
|
||||||
preg_match('/\/dev\/sd[a-z]/', $data['title'], $deviceMatches);
|
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches);
|
||||||
$isDriveTicket = !empty($deviceMatches);
|
$isDriveTicket = !empty($deviceMatches);
|
||||||
|
|
||||||
// Extract hostname from title [hostname][tags]...
|
// Extract hostname from title [hostname][tags]...
|
||||||
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
|
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
|
||||||
$hostname = $hostMatches[1] ?? '';
|
$hostname = $hostMatches[1] ?? '';
|
||||||
|
|
||||||
// Extract SMART attribute types without their values
|
// Detect issue category (not specific attribute values)
|
||||||
preg_match_all('/Warning ([^:]+)/', $data['title'], $smartMatches);
|
$issueCategory = '';
|
||||||
$smartAttributes = $smartMatches[1] ?? [];
|
if (stripos($data['title'], 'SMART issues') !== false) {
|
||||||
|
$issueCategory = 'smart';
|
||||||
|
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) {
|
||||||
|
$issueCategory = 'storage';
|
||||||
|
} elseif (stripos($data['title'], 'memory') !== false) {
|
||||||
|
$issueCategory = 'memory';
|
||||||
|
} elseif (stripos($data['title'], 'cpu') !== false) {
|
||||||
|
$issueCategory = 'cpu';
|
||||||
|
} elseif (stripos($data['title'], 'network') !== false) {
|
||||||
|
$issueCategory = 'network';
|
||||||
|
}
|
||||||
|
|
||||||
// Build stable components with only static data
|
// Build stable components with only static data
|
||||||
$stableComponents = [
|
$stableComponents = [
|
||||||
'hostname' => $hostname,
|
'hostname' => $hostname,
|
||||||
'smart_attributes' => $smartAttributes,
|
'issue_category' => $issueCategory, // Generic category, not specific errors
|
||||||
'environment_tags' => array_filter(
|
'environment_tags' => array_filter(
|
||||||
explode('][', $data['title']),
|
explode('][', $data['title']),
|
||||||
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node'])
|
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node'])
|
||||||
@@ -87,7 +122,6 @@ function generateTicketHash($data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort arrays for consistent hashing
|
// Sort arrays for consistent hashing
|
||||||
sort($stableComponents['smart_attributes']);
|
|
||||||
sort($stableComponents['environment_tags']);
|
sort($stableComponents['environment_tags']);
|
||||||
|
|
||||||
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
|
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
|
||||||
@@ -122,9 +156,9 @@ if (!$data) {
|
|||||||
// Generate ticket ID (9-digit format with leading zeros)
|
// Generate ticket ID (9-digit format with leading zeros)
|
||||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
||||||
|
|
||||||
// Prepare insert query
|
// Prepare insert query with created_by field
|
||||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash)
|
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
// First, store all values in variables
|
// First, store all values in variables
|
||||||
@@ -137,7 +171,7 @@ $type = $data['type'] ?? 'Issue';
|
|||||||
|
|
||||||
// Then use the variables in bind_param
|
// Then use the variables in bind_param
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"ssssssss",
|
"ssssssssi",
|
||||||
$ticket_id,
|
$ticket_id,
|
||||||
$title,
|
$title,
|
||||||
$description,
|
$description,
|
||||||
@@ -145,10 +179,20 @@ $stmt->bind_param(
|
|||||||
$priority,
|
$priority,
|
||||||
$category,
|
$category,
|
||||||
$type,
|
$type,
|
||||||
$ticketHash
|
$ticketHash,
|
||||||
|
$userId
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
|
// Log ticket creation to audit log
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$auditLog->logTicketCreate($userId, $ticket_id, [
|
||||||
|
'title' => $title,
|
||||||
|
'priority' => $priority,
|
||||||
|
'category' => $category,
|
||||||
|
'type' => $type
|
||||||
|
]);
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'ticket_id' => $ticket_id,
|
'ticket_id' => $ticket_id,
|
||||||
@@ -180,7 +224,7 @@ $discord_data = [
|
|||||||
"embeds" => [[
|
"embeds" => [[
|
||||||
"title" => "New Ticket Created: #" . $ticket_id,
|
"title" => "New Ticket Created: #" . $ticket_id,
|
||||||
"description" => $title,
|
"description" => $title,
|
||||||
"url" => "http://tinkertickets.local/ticket/" . $ticket_id,
|
"url" => "http://t.lotusguild.org/ticket/" . $ticket_id,
|
||||||
"color" => $priorityColors[$priority],
|
"color" => $priorityColors[$priority],
|
||||||
"fields" => [
|
"fields" => [
|
||||||
["name" => "Priority", "value" => $priority, "inline" => true],
|
["name" => "Priority", "value" => $priority, "inline" => true],
|
||||||
|
|||||||
101
generate_api_key.php
Normal file
101
generate_api_key.php
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* API Key Generator for hwmonDaemon
|
||||||
|
* Run this script once after migrations to generate the API key
|
||||||
|
*
|
||||||
|
* Usage: php generate_api_key.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config/config.php';
|
||||||
|
require_once __DIR__ . '/models/ApiKeyModel.php';
|
||||||
|
require_once __DIR__ . '/models/UserModel.php';
|
||||||
|
|
||||||
|
echo "==============================================\n";
|
||||||
|
echo " Tinker Tickets - API Key Generator\n";
|
||||||
|
echo "==============================================\n\n";
|
||||||
|
|
||||||
|
// Create database connection
|
||||||
|
$conn = new mysqli(
|
||||||
|
$GLOBALS['config']['DB_HOST'],
|
||||||
|
$GLOBALS['config']['DB_USER'],
|
||||||
|
$GLOBALS['config']['DB_PASS'],
|
||||||
|
$GLOBALS['config']['DB_NAME']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($conn->connect_error) {
|
||||||
|
die("❌ Database connection failed: " . $conn->connect_error . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "✅ Connected to database\n\n";
|
||||||
|
|
||||||
|
// Initialize models
|
||||||
|
$userModel = new UserModel($conn);
|
||||||
|
$apiKeyModel = new ApiKeyModel($conn);
|
||||||
|
|
||||||
|
// Get system user (should exist from migration)
|
||||||
|
echo "Checking for system user...\n";
|
||||||
|
$systemUser = $userModel->getSystemUser();
|
||||||
|
|
||||||
|
if (!$systemUser) {
|
||||||
|
die("❌ Error: System user not found. Please run migrations first.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "✅ System user found: ID " . $systemUser['user_id'] . " (" . $systemUser['username'] . ")\n\n";
|
||||||
|
|
||||||
|
// Check if API key already exists
|
||||||
|
$existingKeys = $apiKeyModel->getKeysByUser($systemUser['user_id']);
|
||||||
|
if (!empty($existingKeys)) {
|
||||||
|
echo "⚠️ Warning: API keys already exist for system user:\n\n";
|
||||||
|
foreach ($existingKeys as $key) {
|
||||||
|
echo " - " . $key['key_name'] . " (Prefix: " . $key['key_prefix'] . ")\n";
|
||||||
|
echo " Created: " . $key['created_at'] . "\n";
|
||||||
|
echo " Active: " . ($key['is_active'] ? 'Yes' : 'No') . "\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Do you want to generate a new API key? (yes/no): ";
|
||||||
|
$handle = fopen("php://stdin", "r");
|
||||||
|
$response = trim(fgets($handle));
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
if (strtolower($response) !== 'yes') {
|
||||||
|
echo "\nAborted.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate API key
|
||||||
|
echo "Generating API key for hwmonDaemon...\n";
|
||||||
|
$result = $apiKeyModel->createKey(
|
||||||
|
'hwmonDaemon',
|
||||||
|
$systemUser['user_id'],
|
||||||
|
null // No expiration
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
echo "\n";
|
||||||
|
echo "==============================================\n";
|
||||||
|
echo " ✅ API Key Generated Successfully!\n";
|
||||||
|
echo "==============================================\n\n";
|
||||||
|
echo "API Key: " . $result['api_key'] . "\n";
|
||||||
|
echo "Key Prefix: " . $result['key_prefix'] . "\n";
|
||||||
|
echo "Key ID: " . $result['key_id'] . "\n";
|
||||||
|
echo "Expires: Never\n\n";
|
||||||
|
echo "⚠️ IMPORTANT: Save this API key now!\n";
|
||||||
|
echo " It cannot be retrieved later.\n\n";
|
||||||
|
echo "==============================================\n";
|
||||||
|
echo " Add to hwmonDaemon .env file:\n";
|
||||||
|
echo "==============================================\n\n";
|
||||||
|
echo "TICKET_API_KEY=" . $result['api_key'] . "\n\n";
|
||||||
|
echo "Then restart hwmonDaemon:\n";
|
||||||
|
echo " sudo systemctl restart hwmonDaemon\n\n";
|
||||||
|
} else {
|
||||||
|
echo "❌ Error generating API key: " . $result['error'] . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
|
echo "Done! Delete this script after use:\n";
|
||||||
|
echo " rm " . __FILE__ . "\n\n";
|
||||||
|
?>
|
||||||
12
index.php
12
index.php
@@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
// Main entry point for the application
|
// Main entry point for the application
|
||||||
require_once 'config/config.php';
|
require_once 'config/config.php';
|
||||||
|
require_once 'middleware/AuthMiddleware.php';
|
||||||
|
require_once 'models/AuditLogModel.php';
|
||||||
|
|
||||||
// Parse the URL - no need to remove base path since we're at document root
|
// Parse the URL - no need to remove base path since we're at document root
|
||||||
$request = $_SERVER['REQUEST_URI'];
|
$request = $_SERVER['REQUEST_URI'];
|
||||||
@@ -20,6 +22,16 @@ if (!str_starts_with($requestPath, '/api/')) {
|
|||||||
if ($conn->connect_error) {
|
if ($conn->connect_error) {
|
||||||
die("Connection failed: " . $conn->connect_error);
|
die("Connection failed: " . $conn->connect_error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authenticate user via Authelia forward auth
|
||||||
|
$authMiddleware = new AuthMiddleware($conn);
|
||||||
|
$currentUser = $authMiddleware->authenticate();
|
||||||
|
|
||||||
|
// Store current user in globals for controllers
|
||||||
|
$GLOBALS['currentUser'] = $currentUser;
|
||||||
|
|
||||||
|
// Initialize audit log model
|
||||||
|
$GLOBALS['auditLog'] = new AuditLogModel($conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple router
|
// Simple router
|
||||||
|
|||||||
141
middleware/ApiKeyAuth.php
Normal file
141
middleware/ApiKeyAuth.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* ApiKeyAuth - Handles API key authentication for external services
|
||||||
|
*/
|
||||||
|
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
|
|
||||||
|
class ApiKeyAuth {
|
||||||
|
private $apiKeyModel;
|
||||||
|
private $userModel;
|
||||||
|
private $conn;
|
||||||
|
|
||||||
|
public function __construct($conn) {
|
||||||
|
$this->conn = $conn;
|
||||||
|
$this->apiKeyModel = new ApiKeyModel($conn);
|
||||||
|
$this->userModel = new UserModel($conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate using API key from Authorization header
|
||||||
|
*
|
||||||
|
* @return array User data for system user
|
||||||
|
* @throws Exception if authentication fails
|
||||||
|
*/
|
||||||
|
public function authenticate() {
|
||||||
|
// Get Authorization header
|
||||||
|
$authHeader = $this->getAuthorizationHeader();
|
||||||
|
|
||||||
|
if (empty($authHeader)) {
|
||||||
|
$this->sendUnauthorized('Missing Authorization header');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a Bearer token
|
||||||
|
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
||||||
|
$this->sendUnauthorized('Invalid Authorization header format. Expected: Bearer <api_key>');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKey = $matches[1];
|
||||||
|
|
||||||
|
// Validate API key
|
||||||
|
$keyData = $this->apiKeyModel->validateKey($apiKey);
|
||||||
|
|
||||||
|
if (!$keyData) {
|
||||||
|
$this->sendUnauthorized('Invalid or expired API key');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get system user (or the user who created the key)
|
||||||
|
$user = $this->userModel->getSystemUser();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$this->sendUnauthorized('System user not found');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add API key info to user data for logging
|
||||||
|
$user['api_key_id'] = $keyData['api_key_id'];
|
||||||
|
$user['api_key_name'] = $keyData['key_name'];
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Authorization header from various sources
|
||||||
|
*
|
||||||
|
* @return string|null Authorization header value
|
||||||
|
*/
|
||||||
|
private function getAuthorizationHeader() {
|
||||||
|
// Try different header formats
|
||||||
|
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||||
|
return $_SERVER['HTTP_AUTHORIZATION'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
|
||||||
|
return $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Authorization in getallheaders if available
|
||||||
|
if (function_exists('getallheaders')) {
|
||||||
|
$headers = getallheaders();
|
||||||
|
if (isset($headers['Authorization'])) {
|
||||||
|
return $headers['Authorization'];
|
||||||
|
}
|
||||||
|
if (isset($headers['authorization'])) {
|
||||||
|
return $headers['authorization'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send 401 Unauthorized response
|
||||||
|
*
|
||||||
|
* @param string $message Error message
|
||||||
|
*/
|
||||||
|
private function sendUnauthorized($message) {
|
||||||
|
header('HTTP/1.1 401 Unauthorized');
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Unauthorized',
|
||||||
|
'message' => $message
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify API key without throwing errors (for optional auth)
|
||||||
|
*
|
||||||
|
* @return array|null User data or null if not authenticated
|
||||||
|
*/
|
||||||
|
public function verifyOptional() {
|
||||||
|
$authHeader = $this->getAuthorizationHeader();
|
||||||
|
|
||||||
|
if (empty($authHeader)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKey = $matches[1];
|
||||||
|
$keyData = $this->apiKeyModel->validateKey($apiKey);
|
||||||
|
|
||||||
|
if (!$keyData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->userModel->getSystemUser();
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
$user['api_key_id'] = $keyData['api_key_id'];
|
||||||
|
$user['api_key_name'] = $keyData['key_name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
269
middleware/AuthMiddleware.php
Normal file
269
middleware/AuthMiddleware.php
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* AuthMiddleware - Handles authentication via Authelia forward auth headers
|
||||||
|
*/
|
||||||
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
|
|
||||||
|
class AuthMiddleware {
|
||||||
|
private $userModel;
|
||||||
|
private $conn;
|
||||||
|
|
||||||
|
public function __construct($conn) {
|
||||||
|
$this->conn = $conn;
|
||||||
|
$this->userModel = new UserModel($conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate user from Authelia forward auth headers
|
||||||
|
*
|
||||||
|
* @return array User data array
|
||||||
|
* @throws Exception if authentication fails
|
||||||
|
*/
|
||||||
|
public function authenticate() {
|
||||||
|
// Start session if not already started with secure settings
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
// Configure secure session settings
|
||||||
|
ini_set('session.cookie_httponly', 1);
|
||||||
|
ini_set('session.cookie_secure', 1); // Requires HTTPS
|
||||||
|
ini_set('session.cookie_samesite', 'Strict');
|
||||||
|
ini_set('session.use_strict_mode', 1);
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is already authenticated in session
|
||||||
|
if (isset($_SESSION['user']) && isset($_SESSION['user']['user_id'])) {
|
||||||
|
// Verify session hasn't expired (5 hour timeout)
|
||||||
|
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > 18000)) {
|
||||||
|
// Session expired, clear it
|
||||||
|
session_unset();
|
||||||
|
session_destroy();
|
||||||
|
session_start();
|
||||||
|
} else {
|
||||||
|
// Update last activity time
|
||||||
|
$_SESSION['last_activity'] = time();
|
||||||
|
return $_SESSION['user'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read Authelia forward auth headers
|
||||||
|
$username = $this->getHeader('HTTP_REMOTE_USER');
|
||||||
|
$displayName = $this->getHeader('HTTP_REMOTE_NAME');
|
||||||
|
$email = $this->getHeader('HTTP_REMOTE_EMAIL');
|
||||||
|
$groups = $this->getHeader('HTTP_REMOTE_GROUPS');
|
||||||
|
|
||||||
|
// Check if authentication headers are present
|
||||||
|
if (empty($username)) {
|
||||||
|
// No auth headers - user not authenticated
|
||||||
|
$this->redirectToAuth();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has required group membership
|
||||||
|
if (!$this->checkGroupAccess($groups)) {
|
||||||
|
$this->showAccessDenied($username, $groups);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync user to database (create or update)
|
||||||
|
$user = $this->userModel->syncUserFromAuthelia($username, $displayName, $email, $groups);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
throw new Exception("Failed to sync user from Authelia");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate session ID to prevent session fixation attacks
|
||||||
|
session_regenerate_id(true);
|
||||||
|
|
||||||
|
// Store user in session
|
||||||
|
$_SESSION['user'] = $user;
|
||||||
|
$_SESSION['last_activity'] = time();
|
||||||
|
|
||||||
|
// Generate new CSRF token on login
|
||||||
|
require_once __DIR__ . '/CsrfMiddleware.php';
|
||||||
|
CsrfMiddleware::generateToken();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get header value from server variables
|
||||||
|
*
|
||||||
|
* @param string $header Header name
|
||||||
|
* @return string|null Header value or null if not set
|
||||||
|
*/
|
||||||
|
private function getHeader($header) {
|
||||||
|
if (isset($_SERVER[$header])) {
|
||||||
|
return $_SERVER[$header];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has required group membership
|
||||||
|
*
|
||||||
|
* @param string $groups Comma-separated group names
|
||||||
|
* @return bool True if user has access
|
||||||
|
*/
|
||||||
|
private function checkGroupAccess($groups) {
|
||||||
|
if (empty($groups)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for admin or employee group membership
|
||||||
|
$userGroups = array_map('trim', explode(',', strtolower($groups)));
|
||||||
|
$requiredGroups = ['admin', 'employee'];
|
||||||
|
|
||||||
|
return !empty(array_intersect($userGroups, $requiredGroups));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect to Authelia login
|
||||||
|
*/
|
||||||
|
private function redirectToAuth() {
|
||||||
|
// Redirect to the auth endpoint (Authelia will handle the redirect back)
|
||||||
|
header('HTTP/1.1 401 Unauthorized');
|
||||||
|
echo '<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Authentication Required</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.auth-container {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
.auth-container h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.auth-container p {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.auth-container a {
|
||||||
|
display: inline-block;
|
||||||
|
background: #4285f4;
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.auth-container a:hover {
|
||||||
|
background: #357ae8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-container">
|
||||||
|
<h1>Authentication Required</h1>
|
||||||
|
<p>You need to be logged in to access Tinker Tickets.</p>
|
||||||
|
<a href="/">Continue to Login</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show access denied page
|
||||||
|
*
|
||||||
|
* @param string $username Username
|
||||||
|
* @param string $groups User groups
|
||||||
|
*/
|
||||||
|
private function showAccessDenied($username, $groups) {
|
||||||
|
header('HTTP/1.1 403 Forbidden');
|
||||||
|
echo '<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Access Denied</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.denied-container {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
.denied-container h1 {
|
||||||
|
color: #d32f2f;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.denied-container p {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.denied-container .user-info {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="denied-container">
|
||||||
|
<h1>Access Denied</h1>
|
||||||
|
<p>You do not have permission to access Tinker Tickets.</p>
|
||||||
|
<p>Required groups: <strong>admin</strong> or <strong>employee</strong></p>
|
||||||
|
<div class="user-info">
|
||||||
|
<div>Username: ' . htmlspecialchars($username) . '</div>
|
||||||
|
<div>Groups: ' . htmlspecialchars($groups ?: 'none') . '</div>
|
||||||
|
</div>
|
||||||
|
<p>Please contact your administrator if you believe this is an error.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current authenticated user from session
|
||||||
|
*
|
||||||
|
* @return array|null User data or null if not authenticated
|
||||||
|
*/
|
||||||
|
public static function getCurrentUser() {
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $_SESSION['user'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout current user
|
||||||
|
*/
|
||||||
|
public static function logout() {
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
session_unset();
|
||||||
|
session_destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
55
middleware/CsrfMiddleware.php
Normal file
55
middleware/CsrfMiddleware.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* CSRF Protection Middleware
|
||||||
|
* Generates and validates CSRF tokens for all state-changing operations
|
||||||
|
*/
|
||||||
|
class CsrfMiddleware {
|
||||||
|
private static $tokenName = 'csrf_token';
|
||||||
|
private static $tokenTime = 'csrf_token_time';
|
||||||
|
private static $tokenLifetime = 3600; // 1 hour
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new CSRF token
|
||||||
|
*/
|
||||||
|
public static function generateToken() {
|
||||||
|
$_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
|
||||||
|
$_SESSION[self::$tokenTime] = time();
|
||||||
|
return $_SESSION[self::$tokenName];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current CSRF token, regenerate if expired
|
||||||
|
*/
|
||||||
|
public static function getToken() {
|
||||||
|
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
|
||||||
|
return self::generateToken();
|
||||||
|
}
|
||||||
|
return $_SESSION[self::$tokenName];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate CSRF token (constant-time comparison)
|
||||||
|
*/
|
||||||
|
public static function validateToken($token) {
|
||||||
|
if (!isset($_SESSION[self::$tokenName])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isTokenExpired()) {
|
||||||
|
self::generateToken(); // Auto-regenerate expired token
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant-time comparison to prevent timing attacks
|
||||||
|
return hash_equals($_SESSION[self::$tokenName], $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if token is expired
|
||||||
|
*/
|
||||||
|
private static function isTokenExpired() {
|
||||||
|
return !isset($_SESSION[self::$tokenTime]) ||
|
||||||
|
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
17
migrations/001_create_users_table.sql
Normal file
17
migrations/001_create_users_table.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- Create users table for SSO integration
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
user_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
email VARCHAR(255),
|
||||||
|
groups TEXT,
|
||||||
|
is_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
last_login TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_username (username)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Insert system user for hwmonDaemon
|
||||||
|
INSERT INTO users (username, display_name, email, groups, is_admin, created_at)
|
||||||
|
VALUES ('system', 'System', 'system@lotusguild.org', '', FALSE, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE username = username;
|
||||||
15
migrations/002_create_api_keys_table.sql
Normal file
15
migrations/002_create_api_keys_table.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Create API keys table for external service authentication
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
api_key_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
key_name VARCHAR(100) NOT NULL,
|
||||||
|
key_hash VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
key_prefix VARCHAR(20) NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_by INT,
|
||||||
|
last_used TIMESTAMP NULL,
|
||||||
|
expires_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_key_hash (key_hash),
|
||||||
|
INDEX idx_is_active (is_active)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
16
migrations/003_create_audit_log_table.sql
Normal file
16
migrations/003_create_audit_log_table.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Create audit log table for tracking all user actions
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
audit_id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT,
|
||||||
|
action_type VARCHAR(50) NOT NULL,
|
||||||
|
entity_type VARCHAR(50) NOT NULL,
|
||||||
|
entity_id VARCHAR(50),
|
||||||
|
details JSON,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_created_at (created_at),
|
||||||
|
INDEX idx_entity (entity_type, entity_id),
|
||||||
|
INDEX idx_action_type (action_type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
30
migrations/004_alter_tickets_table.sql
Normal file
30
migrations/004_alter_tickets_table.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- Add user tracking columns to tickets table
|
||||||
|
ALTER TABLE tickets
|
||||||
|
ADD COLUMN IF NOT EXISTS created_by INT,
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_by INT,
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NULL;
|
||||||
|
|
||||||
|
-- Add foreign key constraints if they don't exist
|
||||||
|
SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||||
|
WHERE CONSTRAINT_NAME = 'fk_tickets_created_by'
|
||||||
|
AND TABLE_NAME = 'tickets'
|
||||||
|
AND TABLE_SCHEMA = DATABASE());
|
||||||
|
|
||||||
|
SET @sql = IF(@fk_exists = 0,
|
||||||
|
'ALTER TABLE tickets ADD CONSTRAINT fk_tickets_created_by FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL',
|
||||||
|
'SELECT "Foreign key fk_tickets_created_by already exists"');
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||||
|
WHERE CONSTRAINT_NAME = 'fk_tickets_updated_by'
|
||||||
|
AND TABLE_NAME = 'tickets'
|
||||||
|
AND TABLE_SCHEMA = DATABASE());
|
||||||
|
|
||||||
|
SET @sql = IF(@fk_exists = 0,
|
||||||
|
'ALTER TABLE tickets ADD CONSTRAINT fk_tickets_updated_by FOREIGN KEY (updated_by) REFERENCES users(user_id) ON DELETE SET NULL',
|
||||||
|
'SELECT "Foreign key fk_tickets_updated_by already exists"');
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
19
migrations/005_alter_comments_table.sql
Normal file
19
migrations/005_alter_comments_table.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- Add user_id column to ticket_comments table
|
||||||
|
ALTER TABLE ticket_comments
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INT;
|
||||||
|
|
||||||
|
-- Add foreign key constraint if it doesn't exist
|
||||||
|
SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||||
|
WHERE CONSTRAINT_NAME = 'fk_comments_user_id'
|
||||||
|
AND TABLE_NAME = 'ticket_comments'
|
||||||
|
AND TABLE_SCHEMA = DATABASE());
|
||||||
|
|
||||||
|
SET @sql = IF(@fk_exists = 0,
|
||||||
|
'ALTER TABLE ticket_comments ADD CONSTRAINT fk_comments_user_id FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL',
|
||||||
|
'SELECT "Foreign key fk_comments_user_id already exists"');
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- Update existing comments to reference jared user (first admin)
|
||||||
|
-- This will be done after jared user is created via web login
|
||||||
39
migrations/006_add_indexes.sql
Normal file
39
migrations/006_add_indexes.sql
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
-- Add database indexes for performance optimization
|
||||||
|
-- Check and create index on tickets.status
|
||||||
|
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_NAME = 'tickets'
|
||||||
|
AND INDEX_NAME = 'idx_status'
|
||||||
|
AND TABLE_SCHEMA = DATABASE());
|
||||||
|
|
||||||
|
SET @sql = IF(@index_exists = 0,
|
||||||
|
'CREATE INDEX idx_status ON tickets(status)',
|
||||||
|
'SELECT "Index idx_status already exists"');
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- Check and create index on tickets.priority
|
||||||
|
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_NAME = 'tickets'
|
||||||
|
AND INDEX_NAME = 'idx_priority'
|
||||||
|
AND TABLE_SCHEMA = DATABASE());
|
||||||
|
|
||||||
|
SET @sql = IF(@index_exists = 0,
|
||||||
|
'CREATE INDEX idx_priority ON tickets(priority)',
|
||||||
|
'SELECT "Index idx_priority already exists"');
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- Check and create index on tickets.created_at
|
||||||
|
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_NAME = 'tickets'
|
||||||
|
AND INDEX_NAME = 'idx_tickets_created_at'
|
||||||
|
AND TABLE_SCHEMA = DATABASE());
|
||||||
|
|
||||||
|
SET @sql = IF(@index_exists = 0,
|
||||||
|
'CREATE INDEX idx_tickets_created_at ON tickets(created_at)',
|
||||||
|
'SELECT "Index idx_tickets_created_at already exists"');
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
13
migrations/007_add_ticket_assignment.sql
Normal file
13
migrations/007_add_ticket_assignment.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Migration 007: Add ticket assignment functionality
|
||||||
|
-- Adds assigned_to column to tickets table
|
||||||
|
|
||||||
|
-- Add assigned_to column to tickets table
|
||||||
|
ALTER TABLE tickets
|
||||||
|
ADD COLUMN assigned_to INT NULL,
|
||||||
|
ADD CONSTRAINT fk_tickets_assigned_to
|
||||||
|
FOREIGN KEY (assigned_to)
|
||||||
|
REFERENCES users(user_id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add index for performance
|
||||||
|
CREATE INDEX idx_assigned_to ON tickets(assigned_to);
|
||||||
31
migrations/008_add_status_workflows.sql
Normal file
31
migrations/008_add_status_workflows.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-- Migration 008: Add status workflow management
|
||||||
|
-- Creates status_transitions table for workflow validation
|
||||||
|
|
||||||
|
-- Table to define allowed status transitions
|
||||||
|
CREATE TABLE status_transitions (
|
||||||
|
transition_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
from_status VARCHAR(50) NOT NULL,
|
||||||
|
to_status VARCHAR(50) NOT NULL,
|
||||||
|
requires_comment BOOLEAN DEFAULT FALSE,
|
||||||
|
requires_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY unique_transition (from_status, to_status),
|
||||||
|
INDEX idx_from_status (from_status)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert default transitions
|
||||||
|
INSERT INTO status_transitions (from_status, to_status, requires_comment) VALUES
|
||||||
|
('Open', 'In Progress', FALSE),
|
||||||
|
('Open', 'Closed', TRUE),
|
||||||
|
('In Progress', 'Open', FALSE),
|
||||||
|
('In Progress', 'Closed', TRUE),
|
||||||
|
('Closed', 'Open', TRUE),
|
||||||
|
('Closed', 'In Progress', FALSE);
|
||||||
|
|
||||||
|
-- Add new status "Resolved"
|
||||||
|
INSERT INTO status_transitions (from_status, to_status, requires_comment) VALUES
|
||||||
|
('In Progress', 'Resolved', FALSE),
|
||||||
|
('Resolved', 'Closed', FALSE),
|
||||||
|
('Resolved', 'In Progress', TRUE),
|
||||||
|
('Open', 'Resolved', FALSE);
|
||||||
24
migrations/009_add_ticket_templates.sql
Normal file
24
migrations/009_add_ticket_templates.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- Migration 009: Add ticket templates
|
||||||
|
-- Creates ticket_templates table for reusable ticket templates
|
||||||
|
|
||||||
|
CREATE TABLE ticket_templates (
|
||||||
|
template_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
template_name VARCHAR(100) NOT NULL,
|
||||||
|
title_template VARCHAR(255) NOT NULL,
|
||||||
|
description_template TEXT NOT NULL,
|
||||||
|
category VARCHAR(50),
|
||||||
|
type VARCHAR(50),
|
||||||
|
default_priority INT DEFAULT 4,
|
||||||
|
created_by INT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(user_id),
|
||||||
|
INDEX idx_template_name (template_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert default templates
|
||||||
|
INSERT INTO ticket_templates (template_name, title_template, description_template, category, type, default_priority) VALUES
|
||||||
|
('Hardware Failure', 'Hardware Failure: [Device Name]', 'Device: \nIssue: \nError Messages: \nTroubleshooting Done: ', 'Hardware', 'Problem', 2),
|
||||||
|
('Software Installation', 'Install [Software Name]', 'Software: \nVersion: \nLicense Key: \nInstallation Path: ', 'Software', 'Install', 3),
|
||||||
|
('Network Issue', 'Network Issue: [Brief Description]', 'Affected System: \nSymptoms: \nIP Address: \nConnectivity Tests: ', 'Hardware', 'Problem', 2),
|
||||||
|
('Maintenance Request', 'Scheduled Maintenance: [System Name]', 'System: \nMaintenance Type: \nScheduled Date: \nDowntime Expected: ', 'Hardware', 'Maintenance', 4);
|
||||||
43
migrations/009_simplify_status_workflow.sql
Normal file
43
migrations/009_simplify_status_workflow.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
-- Migration 009: Simplify status workflow
|
||||||
|
-- Removes "Resolved" status and adds "Pending" status
|
||||||
|
-- Keeps only: Open, Pending, In Progress, Closed
|
||||||
|
|
||||||
|
-- First, update any existing tickets with "Resolved" status to "Closed"
|
||||||
|
UPDATE tickets SET status = 'Closed' WHERE status = 'Resolved';
|
||||||
|
|
||||||
|
-- Delete all existing transitions with "Resolved"
|
||||||
|
DELETE FROM status_transitions WHERE from_status = 'Resolved' OR to_status = 'Resolved';
|
||||||
|
|
||||||
|
-- Clear all existing transitions to rebuild clean workflow
|
||||||
|
DELETE FROM status_transitions;
|
||||||
|
|
||||||
|
-- Define new simplified workflow with Pending status
|
||||||
|
-- OPEN transitions
|
||||||
|
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
|
||||||
|
('Open', 'Pending', FALSE, FALSE), -- Waiting on external dependency
|
||||||
|
('Open', 'In Progress', FALSE, FALSE), -- Start work
|
||||||
|
('Open', 'Closed', TRUE, FALSE); -- Close without work (duplicate, won't fix, etc.)
|
||||||
|
|
||||||
|
-- PENDING transitions
|
||||||
|
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
|
||||||
|
('Pending', 'Open', FALSE, FALSE), -- Unblock and reopen
|
||||||
|
('Pending', 'In Progress', FALSE, FALSE), -- Start work while pending
|
||||||
|
('Pending', 'Closed', TRUE, FALSE); -- Close while pending
|
||||||
|
|
||||||
|
-- IN PROGRESS transitions
|
||||||
|
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
|
||||||
|
('In Progress', 'Open', FALSE, FALSE), -- Stop work, back to queue
|
||||||
|
('In Progress', 'Pending', FALSE, FALSE), -- Blocked by external dependency
|
||||||
|
('In Progress', 'Closed', TRUE, FALSE); -- Complete and close
|
||||||
|
|
||||||
|
-- CLOSED transitions
|
||||||
|
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
|
||||||
|
('Closed', 'Open', TRUE, FALSE), -- Reopen (requires explanation)
|
||||||
|
('Closed', 'In Progress', FALSE, FALSE); -- Reopen and start work immediately
|
||||||
|
|
||||||
|
-- Verify new transitions
|
||||||
|
SELECT 'New Status Transitions:' as info;
|
||||||
|
SELECT from_status, to_status, requires_comment, requires_admin
|
||||||
|
FROM status_transitions
|
||||||
|
WHERE is_active = TRUE
|
||||||
|
ORDER BY from_status, to_status;
|
||||||
19
migrations/010_add_bulk_operations.sql
Normal file
19
migrations/010_add_bulk_operations.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- Migration 010: Add bulk operations tracking
|
||||||
|
-- Creates bulk_operations table for admin bulk actions
|
||||||
|
|
||||||
|
CREATE TABLE bulk_operations (
|
||||||
|
operation_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
operation_type VARCHAR(50) NOT NULL,
|
||||||
|
ticket_ids TEXT NOT NULL, -- Comma-separated
|
||||||
|
performed_by INT NOT NULL,
|
||||||
|
parameters JSON,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
total_tickets INT,
|
||||||
|
processed_tickets INT DEFAULT 0,
|
||||||
|
failed_tickets INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP NULL,
|
||||||
|
FOREIGN KEY (performed_by) REFERENCES users(user_id),
|
||||||
|
INDEX idx_performed_by (performed_by),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
);
|
||||||
18
migrations/010_expand_status_column.sql
Normal file
18
migrations/010_expand_status_column.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Migration 010: Expand status column to accommodate longer status names
|
||||||
|
-- The status column was likely VARCHAR(10) which can't fit "In Progress" or "Pending"
|
||||||
|
|
||||||
|
-- Check current column definition
|
||||||
|
SHOW COLUMNS FROM tickets LIKE 'status';
|
||||||
|
|
||||||
|
-- Expand the status column to accommodate longer status names
|
||||||
|
ALTER TABLE tickets
|
||||||
|
MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'Open';
|
||||||
|
|
||||||
|
-- Verify the change
|
||||||
|
SHOW COLUMNS FROM tickets LIKE 'status';
|
||||||
|
|
||||||
|
-- Show current status distribution
|
||||||
|
SELECT status, COUNT(*) as count
|
||||||
|
FROM tickets
|
||||||
|
GROUP BY status
|
||||||
|
ORDER BY status;
|
||||||
48
migrations/011_create_user_preferences.sql
Normal file
48
migrations/011_create_user_preferences.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
-- Migration 011: Create user_preferences table for persistent user settings
|
||||||
|
-- Stores user-specific preferences like rows per page, default filters, etc.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
preference_key VARCHAR(100) NOT NULL,
|
||||||
|
preference_value TEXT,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY unique_user_pref (user_id, preference_key),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Default preferences for existing users
|
||||||
|
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||||
|
SELECT user_id, 'rows_per_page', '15' FROM users
|
||||||
|
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'rows_per_page');
|
||||||
|
|
||||||
|
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||||
|
SELECT user_id, 'default_status_filters', 'Open,Pending,In Progress' FROM users
|
||||||
|
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'default_status_filters');
|
||||||
|
|
||||||
|
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||||
|
SELECT user_id, 'table_density', 'normal' FROM users
|
||||||
|
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'table_density');
|
||||||
|
|
||||||
|
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||||
|
SELECT user_id, 'notifications_enabled', '1' FROM users
|
||||||
|
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'notifications_enabled');
|
||||||
|
|
||||||
|
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||||
|
SELECT user_id, 'sound_effects', '1' FROM users
|
||||||
|
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'sound_effects');
|
||||||
|
|
||||||
|
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||||
|
SELECT user_id, 'toast_duration', '3000' FROM users
|
||||||
|
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'toast_duration');
|
||||||
|
|
||||||
|
-- Verify table created
|
||||||
|
SELECT 'User Preferences Table Created' as info;
|
||||||
|
DESCRIBE user_preferences;
|
||||||
|
|
||||||
|
-- Show count of preferences
|
||||||
|
SELECT 'Default Preferences Inserted' as info;
|
||||||
|
SELECT preference_key, COUNT(*) as user_count
|
||||||
|
FROM user_preferences
|
||||||
|
GROUP BY preference_key
|
||||||
|
ORDER BY preference_key;
|
||||||
2
migrations/011_remove_view_tracking.sql
Normal file
2
migrations/011_remove_view_tracking.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Remove all ticket view tracking records from audit_log
|
||||||
|
DELETE FROM audit_log WHERE action_type = 'view';
|
||||||
15
migrations/012_create_saved_filters.sql
Normal file
15
migrations/012_create_saved_filters.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Create saved_filters table for storing user's custom search filters
|
||||||
|
CREATE TABLE IF NOT EXISTS saved_filters (
|
||||||
|
filter_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
filter_name VARCHAR(100) NOT NULL,
|
||||||
|
filter_criteria JSON NOT NULL,
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY unique_user_filter_name (user_id, filter_name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Create index for faster lookups
|
||||||
|
CREATE INDEX idx_user_filters ON saved_filters(user_id, is_default);
|
||||||
11
migrations/013_add_performance_indexes.sql
Normal file
11
migrations/013_add_performance_indexes.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Migration 013: Add performance indexes for critical queries
|
||||||
|
|
||||||
|
-- Index on ticket_comments.ticket_id (foreign key without index)
|
||||||
|
-- Speeds up comment loading by 10-100x on large tables
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_comments_ticket_id
|
||||||
|
ON ticket_comments(ticket_id);
|
||||||
|
|
||||||
|
-- Composite index on audit_log for entity lookups with date sorting
|
||||||
|
-- Optimizes activity timeline queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_entity_created
|
||||||
|
ON audit_log(entity_type, entity_id, created_at DESC);
|
||||||
4
migrations/013_rollback.sql
Normal file
4
migrations/013_rollback.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- Rollback for migration 013: Remove performance indexes
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_ticket_comments_ticket_id ON ticket_comments;
|
||||||
|
DROP INDEX IF EXISTS idx_audit_entity_created ON audit_log;
|
||||||
118
migrations/MIGRATION_009_INSTRUCTIONS.md
Normal file
118
migrations/MIGRATION_009_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Migration 009: Simplify Status Workflow
|
||||||
|
|
||||||
|
This migration removes the "Resolved" status and adds a "Pending" status to the ticket system.
|
||||||
|
|
||||||
|
## Status Changes
|
||||||
|
|
||||||
|
### Before (4 statuses):
|
||||||
|
- Open
|
||||||
|
- In Progress
|
||||||
|
- **Resolved** ❌ (being removed)
|
||||||
|
- Closed
|
||||||
|
|
||||||
|
### After (4 statuses):
|
||||||
|
- Open
|
||||||
|
- **Pending** ✅ (new)
|
||||||
|
- In Progress
|
||||||
|
- Closed
|
||||||
|
|
||||||
|
## What "Pending" Means
|
||||||
|
|
||||||
|
**Pending** status indicates a ticket is waiting on:
|
||||||
|
- External dependencies
|
||||||
|
- Third-party responses
|
||||||
|
- Parts/equipment to arrive
|
||||||
|
- Customer information
|
||||||
|
- Approval from another team
|
||||||
|
|
||||||
|
Unlike "In Progress" which means active work is happening, "Pending" means the ticket is blocked and waiting.
|
||||||
|
|
||||||
|
## Running the Migration
|
||||||
|
|
||||||
|
On the tinkertickets server, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/html/tinkertickets/migrations
|
||||||
|
mysql -h 10.10.10.50 -u tinkertickets -p'&*woX!5R$x8Tyrm7zNxC' ticketing_system < 009_simplify_status_workflow.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## What the Migration Does
|
||||||
|
|
||||||
|
1. Updates any existing tickets with status "Resolved" to "Closed"
|
||||||
|
2. Deletes all status transitions involving "Resolved"
|
||||||
|
3. Creates new workflow with "Pending" status
|
||||||
|
4. Sets up the following allowed transitions:
|
||||||
|
|
||||||
|
### New Workflow Transitions:
|
||||||
|
|
||||||
|
**From Open:**
|
||||||
|
- → Pending (no comment required)
|
||||||
|
- → In Progress (no comment required)
|
||||||
|
- → Closed (requires comment)
|
||||||
|
|
||||||
|
**From Pending:**
|
||||||
|
- → Open (no comment required)
|
||||||
|
- → In Progress (no comment required)
|
||||||
|
- → Closed (requires comment)
|
||||||
|
|
||||||
|
**From In Progress:**
|
||||||
|
- → Open (no comment required)
|
||||||
|
- → Pending (no comment required)
|
||||||
|
- → Closed (requires comment)
|
||||||
|
|
||||||
|
**From Closed:**
|
||||||
|
- → Open (requires comment - explain why reopening)
|
||||||
|
- → In Progress (no comment required)
|
||||||
|
|
||||||
|
## CSS Updates
|
||||||
|
|
||||||
|
The following CSS files have been updated:
|
||||||
|
- ✅ `/assets/css/dashboard.css` - Added `.status-Pending` styling with purple color (#9c27b0) and pause icon
|
||||||
|
- ✅ `/assets/css/ticket.css` - Added `.status-Pending` styling
|
||||||
|
|
||||||
|
## Visual Appearance
|
||||||
|
|
||||||
|
The Pending status will display as:
|
||||||
|
```
|
||||||
|
[⏸ PENDING]
|
||||||
|
```
|
||||||
|
- Purple color border and text
|
||||||
|
- Pause icon (⏸) to indicate waiting state
|
||||||
|
- Terminal-style glow effect
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After running the migration, verify:
|
||||||
|
|
||||||
|
1. Check that all tickets previously marked "Resolved" are now "Closed":
|
||||||
|
```sql
|
||||||
|
SELECT COUNT(*) FROM tickets WHERE status = 'Resolved'; -- Should be 0
|
||||||
|
SELECT COUNT(*) FROM tickets WHERE status = 'Closed';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check new transitions exist:
|
||||||
|
```sql
|
||||||
|
SELECT from_status, to_status FROM status_transitions
|
||||||
|
WHERE from_status = 'Pending' OR to_status = 'Pending'
|
||||||
|
ORDER BY from_status, to_status;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Test creating a new ticket and changing its status to Pending in the UI
|
||||||
|
|
||||||
|
## Rollback (if needed)
|
||||||
|
|
||||||
|
If you need to rollback this migration:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Restore Resolved status transitions
|
||||||
|
DELETE FROM status_transitions WHERE from_status = 'Pending' OR to_status = 'Pending';
|
||||||
|
|
||||||
|
INSERT INTO status_transitions (from_status, to_status, requires_comment) VALUES
|
||||||
|
('In Progress', 'Resolved', FALSE),
|
||||||
|
('Resolved', 'Closed', FALSE),
|
||||||
|
('Resolved', 'In Progress', TRUE),
|
||||||
|
('Open', 'Resolved', FALSE);
|
||||||
|
|
||||||
|
-- Update any Pending tickets to Open
|
||||||
|
UPDATE tickets SET status = 'Open' WHERE status = 'Pending';
|
||||||
|
```
|
||||||
25
migrations/rollback_all.sql
Normal file
25
migrations/rollback_all.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Rollback script to undo all SSO integration changes
|
||||||
|
-- WARNING: This will delete all user data, API keys, and audit logs
|
||||||
|
|
||||||
|
-- Drop foreign keys first
|
||||||
|
ALTER TABLE ticket_comments DROP FOREIGN KEY IF EXISTS fk_comments_user_id;
|
||||||
|
ALTER TABLE tickets DROP FOREIGN KEY IF EXISTS fk_tickets_created_by;
|
||||||
|
ALTER TABLE tickets DROP FOREIGN KEY IF EXISTS fk_tickets_updated_by;
|
||||||
|
ALTER TABLE api_keys DROP FOREIGN KEY IF EXISTS api_keys_ibfk_1;
|
||||||
|
ALTER TABLE audit_log DROP FOREIGN KEY IF EXISTS audit_log_ibfk_1;
|
||||||
|
|
||||||
|
-- Drop columns from existing tables
|
||||||
|
ALTER TABLE ticket_comments DROP COLUMN IF EXISTS user_id;
|
||||||
|
ALTER TABLE tickets DROP COLUMN IF EXISTS created_by;
|
||||||
|
ALTER TABLE tickets DROP COLUMN IF EXISTS updated_by;
|
||||||
|
ALTER TABLE tickets DROP COLUMN IF EXISTS updated_at;
|
||||||
|
|
||||||
|
-- Drop new tables
|
||||||
|
DROP TABLE IF EXISTS audit_log;
|
||||||
|
DROP TABLE IF EXISTS api_keys;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
|
||||||
|
-- Drop indexes
|
||||||
|
DROP INDEX IF EXISTS idx_status ON tickets;
|
||||||
|
DROP INDEX IF EXISTS idx_priority ON tickets;
|
||||||
|
DROP INDEX IF EXISTS idx_tickets_created_at ON tickets;
|
||||||
107
migrations/run_migrations.php
Normal file
107
migrations/run_migrations.php
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Database Migration Runner
|
||||||
|
* Executes all migration files in order
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 1);
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
$envFile = dirname(__DIR__) . '/.env';
|
||||||
|
if (!file_exists($envFile)) {
|
||||||
|
die("Error: .env file not found at $envFile\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$envVars = parse_ini_file($envFile);
|
||||||
|
if (!$envVars) {
|
||||||
|
die("Error: Could not parse .env file\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to database
|
||||||
|
$conn = new mysqli(
|
||||||
|
$envVars['DB_HOST'],
|
||||||
|
$envVars['DB_USER'],
|
||||||
|
$envVars['DB_PASS'],
|
||||||
|
$envVars['DB_NAME']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($conn->connect_error) {
|
||||||
|
die("Database connection failed: " . $conn->connect_error . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Connected to database: {$envVars['DB_NAME']}\n\n";
|
||||||
|
|
||||||
|
// Get all migration files
|
||||||
|
$migrationFiles = glob(__DIR__ . '/*.sql');
|
||||||
|
sort($migrationFiles);
|
||||||
|
|
||||||
|
// Filter out rollback script
|
||||||
|
$migrationFiles = array_filter($migrationFiles, function($file) {
|
||||||
|
return !strpos($file, 'rollback');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (empty($migrationFiles)) {
|
||||||
|
echo "No migration files found.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Found " . count($migrationFiles) . " migration(s):\n";
|
||||||
|
foreach ($migrationFiles as $file) {
|
||||||
|
echo " - " . basename($file) . "\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Execute each migration
|
||||||
|
$successCount = 0;
|
||||||
|
$errorCount = 0;
|
||||||
|
|
||||||
|
foreach ($migrationFiles as $file) {
|
||||||
|
$filename = basename($file);
|
||||||
|
echo "Executing: $filename... ";
|
||||||
|
|
||||||
|
$sql = file_get_contents($file);
|
||||||
|
|
||||||
|
// Split SQL into individual statements
|
||||||
|
// This handles multi-statement migrations
|
||||||
|
if ($conn->multi_query($sql)) {
|
||||||
|
do {
|
||||||
|
// Store first result set
|
||||||
|
if ($result = $conn->store_result()) {
|
||||||
|
$result->free();
|
||||||
|
}
|
||||||
|
// Check for errors
|
||||||
|
if ($conn->errno) {
|
||||||
|
echo "FAILED\n";
|
||||||
|
echo " Error: " . $conn->error . "\n";
|
||||||
|
$errorCount++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} while ($conn->more_results() && $conn->next_result());
|
||||||
|
|
||||||
|
// If we got through all results without error
|
||||||
|
if (!$conn->errno) {
|
||||||
|
echo "OK\n";
|
||||||
|
$successCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "FAILED\n";
|
||||||
|
echo " Error: " . $conn->error . "\n";
|
||||||
|
$errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
echo "Migration Summary:\n";
|
||||||
|
echo " Success: $successCount\n";
|
||||||
|
echo " Errors: $errorCount\n";
|
||||||
|
|
||||||
|
if ($errorCount > 0) {
|
||||||
|
echo "\nSome migrations failed. Please review errors above.\n";
|
||||||
|
exit(1);
|
||||||
|
} else {
|
||||||
|
echo "\nAll migrations completed successfully!\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
229
models/ApiKeyModel.php
Normal file
229
models/ApiKeyModel.php
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* ApiKeyModel - Handles API key generation and validation
|
||||||
|
*/
|
||||||
|
class ApiKeyModel {
|
||||||
|
private $conn;
|
||||||
|
|
||||||
|
public function __construct($conn) {
|
||||||
|
$this->conn = $conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new API key
|
||||||
|
*
|
||||||
|
* @param string $keyName Descriptive name for the key
|
||||||
|
* @param int $createdBy User ID who created the key
|
||||||
|
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
||||||
|
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
|
||||||
|
*/
|
||||||
|
public function createKey($keyName, $createdBy, $expiresInDays = null) {
|
||||||
|
// Generate random API key (32 bytes = 64 hex characters)
|
||||||
|
$apiKey = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
// Create key prefix (first 8 characters) for identification
|
||||||
|
$keyPrefix = substr($apiKey, 0, 8);
|
||||||
|
|
||||||
|
// Hash the API key for storage
|
||||||
|
$keyHash = hash('sha256', $apiKey);
|
||||||
|
|
||||||
|
// Calculate expiration date if specified
|
||||||
|
$expiresAt = null;
|
||||||
|
if ($expiresInDays !== null) {
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', strtotime("+$expiresInDays days"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert API key into database
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"INSERT INTO api_keys (key_name, key_hash, key_prefix, created_by, expires_at) VALUES (?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("sssis", $keyName, $keyHash, $keyPrefix, $createdBy, $expiresAt);
|
||||||
|
|
||||||
|
if ($stmt->execute()) {
|
||||||
|
$keyId = $this->conn->insert_id;
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'api_key' => $apiKey, // Return plaintext key ONCE
|
||||||
|
'key_prefix' => $keyPrefix,
|
||||||
|
'key_id' => $keyId,
|
||||||
|
'expires_at' => $expiresAt
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$error = $this->conn->error;
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $error
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an API key
|
||||||
|
*
|
||||||
|
* @param string $apiKey Plaintext API key to validate
|
||||||
|
* @return array|null API key record if valid, null if invalid
|
||||||
|
*/
|
||||||
|
public function validateKey($apiKey) {
|
||||||
|
if (empty($apiKey)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the provided key
|
||||||
|
$keyHash = hash('sha256', $apiKey);
|
||||||
|
|
||||||
|
// Query for matching key
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT * FROM api_keys WHERE key_hash = ? AND is_active = 1"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("s", $keyHash);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
if ($result->num_rows === 0) {
|
||||||
|
$stmt->close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyData = $result->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if ($keyData['expires_at'] !== null) {
|
||||||
|
$expiresAt = strtotime($keyData['expires_at']);
|
||||||
|
if ($expiresAt < time()) {
|
||||||
|
return null; // Key has expired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_used timestamp
|
||||||
|
$this->updateLastUsed($keyData['api_key_id']);
|
||||||
|
|
||||||
|
return $keyData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last_used timestamp for an API key
|
||||||
|
*
|
||||||
|
* @param int $keyId API key ID
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
private function updateLastUsed($keyId) {
|
||||||
|
$stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
|
||||||
|
$stmt->bind_param("i", $keyId);
|
||||||
|
$success = $stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
return $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke an API key (set is_active to false)
|
||||||
|
*
|
||||||
|
* @param int $keyId API key ID
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function revokeKey($keyId) {
|
||||||
|
$stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
|
||||||
|
$stmt->bind_param("i", $keyId);
|
||||||
|
$success = $stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
return $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an API key permanently
|
||||||
|
*
|
||||||
|
* @param int $keyId API key ID
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function deleteKey($keyId) {
|
||||||
|
$stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
|
||||||
|
$stmt->bind_param("i", $keyId);
|
||||||
|
$success = $stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
return $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all API keys (for admin panel)
|
||||||
|
*
|
||||||
|
* @return array Array of API key records (without hashes)
|
||||||
|
*/
|
||||||
|
public function getAllKeys() {
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT ak.*, u.username, u.display_name
|
||||||
|
FROM api_keys ak
|
||||||
|
LEFT JOIN users u ON ak.created_by = u.user_id
|
||||||
|
ORDER BY ak.created_at DESC"
|
||||||
|
);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$keys = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
// Remove key_hash from response for security
|
||||||
|
unset($row['key_hash']);
|
||||||
|
$keys[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return $keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get API key by ID
|
||||||
|
*
|
||||||
|
* @param int $keyId API key ID
|
||||||
|
* @return array|null API key record (without hash) or null if not found
|
||||||
|
*/
|
||||||
|
public function getKeyById($keyId) {
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT ak.*, u.username, u.display_name
|
||||||
|
FROM api_keys ak
|
||||||
|
LEFT JOIN users u ON ak.created_by = u.user_id
|
||||||
|
WHERE ak.api_key_id = ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("i", $keyId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
if ($result->num_rows > 0) {
|
||||||
|
$key = $result->fetch_assoc();
|
||||||
|
// Remove key_hash from response for security
|
||||||
|
unset($key['key_hash']);
|
||||||
|
$stmt->close();
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get keys created by a specific user
|
||||||
|
*
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @return array Array of API key records
|
||||||
|
*/
|
||||||
|
public function getKeysByUser($userId) {
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("i", $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$keys = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
// Remove key_hash from response for security
|
||||||
|
unset($row['key_hash']);
|
||||||
|
$keys[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return $keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
443
models/AuditLogModel.php
Normal file
443
models/AuditLogModel.php
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* AuditLogModel - Handles audit trail logging for all user actions
|
||||||
|
*/
|
||||||
|
class AuditLogModel {
|
||||||
|
private $conn;
|
||||||
|
|
||||||
|
public function __construct($conn) {
|
||||||
|
$this->conn = $conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an action to the audit trail
|
||||||
|
*
|
||||||
|
* @param int $userId User ID performing the action
|
||||||
|
* @param string $actionType Type of action (e.g., 'create', 'update', 'delete', 'view')
|
||||||
|
* @param string $entityType Type of entity (e.g., 'ticket', 'comment', 'api_key')
|
||||||
|
* @param string|null $entityId ID of the entity affected
|
||||||
|
* @param array|null $details Additional details as associative array
|
||||||
|
* @param string|null $ipAddress IP address of the user
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null) {
|
||||||
|
// Convert details array to JSON
|
||||||
|
$detailsJson = null;
|
||||||
|
if ($details !== null) {
|
||||||
|
$detailsJson = json_encode($details);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get IP address if not provided
|
||||||
|
if ($ipAddress === null) {
|
||||||
|
$ipAddress = $this->getClientIP();
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"INSERT INTO audit_log (user_id, action_type, entity_type, entity_id, details, ip_address)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("isssss", $userId, $actionType, $entityType, $entityId, $detailsJson, $ipAddress);
|
||||||
|
|
||||||
|
$success = $stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
return $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit logs for a specific entity
|
||||||
|
*
|
||||||
|
* @param string $entityType Type of entity
|
||||||
|
* @param string $entityId ID of the entity
|
||||||
|
* @param int $limit Maximum number of logs to return
|
||||||
|
* @return array Array of audit log records
|
||||||
|
*/
|
||||||
|
public function getLogsByEntity($entityType, $entityId, $limit = 100) {
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT al.*, u.username, u.display_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
WHERE al.entity_type = ? AND al.entity_id = ?
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("ssi", $entityType, $entityId, $limit);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$logs = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
// Decode JSON details
|
||||||
|
if ($row['details']) {
|
||||||
|
$row['details'] = json_decode($row['details'], true);
|
||||||
|
}
|
||||||
|
$logs[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return $logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit logs for a specific user
|
||||||
|
*
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @param int $limit Maximum number of logs to return
|
||||||
|
* @return array Array of audit log records
|
||||||
|
*/
|
||||||
|
public function getLogsByUser($userId, $limit = 100) {
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT al.*, u.username, u.display_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
WHERE al.user_id = ?
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("ii", $userId, $limit);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$logs = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
// Decode JSON details
|
||||||
|
if ($row['details']) {
|
||||||
|
$row['details'] = json_decode($row['details'], true);
|
||||||
|
}
|
||||||
|
$logs[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return $logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent audit logs (for admin panel)
|
||||||
|
*
|
||||||
|
* @param int $limit Maximum number of logs to return
|
||||||
|
* @param int $offset Offset for pagination
|
||||||
|
* @return array Array of audit log records
|
||||||
|
*/
|
||||||
|
public function getRecentLogs($limit = 50, $offset = 0) {
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT al.*, u.username, u.display_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("ii", $limit, $offset);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$logs = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
// Decode JSON details
|
||||||
|
if ($row['details']) {
|
||||||
|
$row['details'] = json_decode($row['details'], true);
|
||||||
|
}
|
||||||
|
$logs[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return $logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit logs filtered by action type
|
||||||
|
*
|
||||||
|
* @param string $actionType Action type to filter by
|
||||||
|
* @param int $limit Maximum number of logs to return
|
||||||
|
* @return array Array of audit log records
|
||||||
|
*/
|
||||||
|
public function getLogsByAction($actionType, $limit = 100) {
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT al.*, u.username, u.display_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
WHERE al.action_type = ?
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("si", $actionType, $limit);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$logs = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
// Decode JSON details
|
||||||
|
if ($row['details']) {
|
||||||
|
$row['details'] = json_decode($row['details'], true);
|
||||||
|
}
|
||||||
|
$logs[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return $logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total count of audit logs
|
||||||
|
*
|
||||||
|
* @return int Total count
|
||||||
|
*/
|
||||||
|
public function getTotalCount() {
|
||||||
|
$result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log");
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
return (int)$row['count'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete old audit logs (for maintenance)
|
||||||
|
*
|
||||||
|
* @param int $daysToKeep Number of days of logs to keep
|
||||||
|
* @return int Number of deleted records
|
||||||
|
*/
|
||||||
|
public function deleteOldLogs($daysToKeep = 90) {
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("i", $daysToKeep);
|
||||||
|
$stmt->execute();
|
||||||
|
$affectedRows = $stmt->affected_rows;
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
return $affectedRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client IP address (handles proxies)
|
||||||
|
*
|
||||||
|
* @return string Client IP address
|
||||||
|
*/
|
||||||
|
private function getClientIP() {
|
||||||
|
$ipAddress = '';
|
||||||
|
|
||||||
|
// Check for proxy headers
|
||||||
|
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
|
||||||
|
// Cloudflare
|
||||||
|
$ipAddress = $_SERVER['HTTP_CF_CONNECTING_IP'];
|
||||||
|
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||||
|
// Nginx proxy
|
||||||
|
$ipAddress = $_SERVER['HTTP_X_REAL_IP'];
|
||||||
|
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||||
|
// Standard proxy header
|
||||||
|
$ipAddress = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0];
|
||||||
|
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||||
|
// Direct connection
|
||||||
|
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Log ticket creation
|
||||||
|
*
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @param string $ticketId Ticket ID
|
||||||
|
* @param array $ticketData Ticket data
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function logTicketCreate($userId, $ticketId, $ticketData) {
|
||||||
|
return $this->log(
|
||||||
|
$userId,
|
||||||
|
'create',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['title' => $ticketData['title'], 'priority' => $ticketData['priority'] ?? null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Log ticket update
|
||||||
|
*
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @param string $ticketId Ticket ID
|
||||||
|
* @param array $changes Array of changed fields
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function logTicketUpdate($userId, $ticketId, $changes) {
|
||||||
|
return $this->log($userId, 'update', 'ticket', $ticketId, $changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Log comment creation
|
||||||
|
*
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @param int $commentId Comment ID
|
||||||
|
* @param string $ticketId Associated ticket ID
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function logCommentCreate($userId, $commentId, $ticketId) {
|
||||||
|
return $this->log(
|
||||||
|
$userId,
|
||||||
|
'create',
|
||||||
|
'comment',
|
||||||
|
(string)$commentId,
|
||||||
|
['ticket_id' => $ticketId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Log ticket view
|
||||||
|
*
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @param string $ticketId Ticket ID
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function logTicketView($userId, $ticketId) {
|
||||||
|
return $this->log($userId, 'view', 'ticket', $ticketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted timeline for a specific ticket
|
||||||
|
* Includes all ticket updates and comments
|
||||||
|
*
|
||||||
|
* @param string $ticketId Ticket ID
|
||||||
|
* @return array Timeline events
|
||||||
|
*/
|
||||||
|
public function getTicketTimeline($ticketId) {
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT al.*, u.username, u.display_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
WHERE (al.entity_type = 'ticket' AND al.entity_id = ?)
|
||||||
|
OR (al.entity_type = 'comment' AND JSON_EXTRACT(al.details, '$.ticket_id') = ?)
|
||||||
|
ORDER BY al.created_at DESC"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("ss", $ticketId, $ticketId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$timeline = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
if ($row['details']) {
|
||||||
|
$row['details'] = json_decode($row['details'], true);
|
||||||
|
}
|
||||||
|
$timeline[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return $timeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filtered audit logs with advanced search
|
||||||
|
*
|
||||||
|
* @param array $filters Associative array of filter criteria
|
||||||
|
* @param int $limit Maximum number of logs to return
|
||||||
|
* @param int $offset Offset for pagination
|
||||||
|
* @return array Array containing logs and total count
|
||||||
|
*/
|
||||||
|
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0) {
|
||||||
|
$whereConditions = [];
|
||||||
|
$params = [];
|
||||||
|
$paramTypes = '';
|
||||||
|
|
||||||
|
// Action type filter
|
||||||
|
if (!empty($filters['action_type'])) {
|
||||||
|
$actions = explode(',', $filters['action_type']);
|
||||||
|
$placeholders = str_repeat('?,', count($actions) - 1) . '?';
|
||||||
|
$whereConditions[] = "al.action_type IN ($placeholders)";
|
||||||
|
$params = array_merge($params, $actions);
|
||||||
|
$paramTypes .= str_repeat('s', count($actions));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity type filter
|
||||||
|
if (!empty($filters['entity_type'])) {
|
||||||
|
$entities = explode(',', $filters['entity_type']);
|
||||||
|
$placeholders = str_repeat('?,', count($entities) - 1) . '?';
|
||||||
|
$whereConditions[] = "al.entity_type IN ($placeholders)";
|
||||||
|
$params = array_merge($params, $entities);
|
||||||
|
$paramTypes .= str_repeat('s', count($entities));
|
||||||
|
}
|
||||||
|
|
||||||
|
// User filter
|
||||||
|
if (!empty($filters['user_id'])) {
|
||||||
|
$whereConditions[] = "al.user_id = ?";
|
||||||
|
$params[] = (int)$filters['user_id'];
|
||||||
|
$paramTypes .= 'i';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity ID filter (for specific ticket/comment)
|
||||||
|
if (!empty($filters['entity_id'])) {
|
||||||
|
$whereConditions[] = "al.entity_id = ?";
|
||||||
|
$params[] = $filters['entity_id'];
|
||||||
|
$paramTypes .= 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date range filters
|
||||||
|
if (!empty($filters['date_from'])) {
|
||||||
|
$whereConditions[] = "DATE(al.created_at) >= ?";
|
||||||
|
$params[] = $filters['date_from'];
|
||||||
|
$paramTypes .= 's';
|
||||||
|
}
|
||||||
|
if (!empty($filters['date_to'])) {
|
||||||
|
$whereConditions[] = "DATE(al.created_at) <= ?";
|
||||||
|
$params[] = $filters['date_to'];
|
||||||
|
$paramTypes .= 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP address filter
|
||||||
|
if (!empty($filters['ip_address'])) {
|
||||||
|
$whereConditions[] = "al.ip_address LIKE ?";
|
||||||
|
$params[] = '%' . $filters['ip_address'] . '%';
|
||||||
|
$paramTypes .= 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build WHERE clause
|
||||||
|
$whereClause = '';
|
||||||
|
if (!empty($whereConditions)) {
|
||||||
|
$whereClause = 'WHERE ' . implode(' AND ', $whereConditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count for pagination
|
||||||
|
$countSql = "SELECT COUNT(*) as total FROM audit_log al $whereClause";
|
||||||
|
$countStmt = $this->conn->prepare($countSql);
|
||||||
|
if (!empty($params)) {
|
||||||
|
$countStmt->bind_param($paramTypes, ...$params);
|
||||||
|
}
|
||||||
|
$countStmt->execute();
|
||||||
|
$totalResult = $countStmt->get_result();
|
||||||
|
$totalCount = $totalResult->fetch_assoc()['total'];
|
||||||
|
$countStmt->close();
|
||||||
|
|
||||||
|
// Get filtered logs
|
||||||
|
$sql = "SELECT al.*, u.username, u.display_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
$whereClause
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|
||||||
|
// Add limit and offset parameters
|
||||||
|
$params[] = $limit;
|
||||||
|
$params[] = $offset;
|
||||||
|
$paramTypes .= 'ii';
|
||||||
|
|
||||||
|
if (!empty($params)) {
|
||||||
|
$stmt->bind_param($paramTypes, ...$params);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$logs = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
if ($row['details']) {
|
||||||
|
$row['details'] = json_decode($row['details'], true);
|
||||||
|
}
|
||||||
|
$logs[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'logs' => $logs,
|
||||||
|
'total' => $totalCount,
|
||||||
|
'pages' => ceil($totalCount / $limit)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
223
models/BulkOperationsModel.php
Normal file
223
models/BulkOperationsModel.php
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* BulkOperationsModel - Handles bulk ticket operations (Admin only)
|
||||||
|
*/
|
||||||
|
class BulkOperationsModel {
|
||||||
|
private $conn;
|
||||||
|
|
||||||
|
public function __construct($conn) {
|
||||||
|
$this->conn = $conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new bulk operation record
|
||||||
|
*
|
||||||
|
* @param string $type Operation type (bulk_close, bulk_assign, bulk_priority)
|
||||||
|
* @param array $ticketIds Array of ticket IDs
|
||||||
|
* @param int $userId User performing the operation
|
||||||
|
* @param array|null $parameters Operation parameters
|
||||||
|
* @return int|false Operation ID or false on failure
|
||||||
|
*/
|
||||||
|
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) {
|
||||||
|
$ticketIdsStr = implode(',', $ticketIds);
|
||||||
|
$totalTickets = count($ticketIds);
|
||||||
|
$parametersJson = $parameters ? json_encode($parameters) : null;
|
||||||
|
|
||||||
|
$sql = "INSERT INTO bulk_operations (operation_type, ticket_ids, performed_by, parameters, total_tickets)
|
||||||
|
VALUES (?, ?, ?, ?, ?)";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("ssisi", $type, $ticketIdsStr, $userId, $parametersJson, $totalTickets);
|
||||||
|
|
||||||
|
if ($stmt->execute()) {
|
||||||
|
$operationId = $this->conn->insert_id;
|
||||||
|
$stmt->close();
|
||||||
|
return $operationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a bulk operation
|
||||||
|
*
|
||||||
|
* @param int $operationId Operation ID
|
||||||
|
* @return array Result with processed and failed counts
|
||||||
|
*/
|
||||||
|
public function processBulkOperation($operationId) {
|
||||||
|
// Get operation details
|
||||||
|
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $operationId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$operation = $result->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
if (!$operation) {
|
||||||
|
return ['processed' => 0, 'failed' => 0, 'error' => 'Operation not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketIds = explode(',', $operation['ticket_ids']);
|
||||||
|
$parameters = $operation['parameters'] ? json_decode($operation['parameters'], true) : [];
|
||||||
|
$processed = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
// Load required models
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
$ticketModel = new TicketModel($this->conn);
|
||||||
|
$auditLogModel = new AuditLogModel($this->conn);
|
||||||
|
|
||||||
|
// Batch load all tickets in one query to eliminate N+1 problem
|
||||||
|
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
|
||||||
|
|
||||||
|
foreach ($ticketIds as $ticketId) {
|
||||||
|
$ticketId = trim($ticketId);
|
||||||
|
$success = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch ($operation['operation_type']) {
|
||||||
|
case 'bulk_close':
|
||||||
|
// Get current ticket from pre-loaded batch
|
||||||
|
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||||
|
if ($currentTicket) {
|
||||||
|
$success = $ticketModel->updateTicket([
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $currentTicket['title'],
|
||||||
|
'description' => $currentTicket['description'],
|
||||||
|
'category' => $currentTicket['category'],
|
||||||
|
'type' => $currentTicket['type'],
|
||||||
|
'status' => 'Closed',
|
||||||
|
'priority' => $currentTicket['priority']
|
||||||
|
], $operation['performed_by']);
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
||||||
|
['status' => 'Closed', 'bulk_operation_id' => $operationId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'bulk_assign':
|
||||||
|
if (isset($parameters['assigned_to'])) {
|
||||||
|
$success = $ticketModel->assignTicket($ticketId, $parameters['assigned_to'], $operation['performed_by']);
|
||||||
|
if ($success) {
|
||||||
|
$auditLogModel->log($operation['performed_by'], 'assign', 'ticket', $ticketId,
|
||||||
|
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'bulk_priority':
|
||||||
|
if (isset($parameters['priority'])) {
|
||||||
|
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||||
|
if ($currentTicket) {
|
||||||
|
$success = $ticketModel->updateTicket([
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $currentTicket['title'],
|
||||||
|
'description' => $currentTicket['description'],
|
||||||
|
'category' => $currentTicket['category'],
|
||||||
|
'type' => $currentTicket['type'],
|
||||||
|
'status' => $currentTicket['status'],
|
||||||
|
'priority' => $parameters['priority']
|
||||||
|
], $operation['performed_by']);
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
||||||
|
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'bulk_status':
|
||||||
|
if (isset($parameters['status'])) {
|
||||||
|
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||||
|
if ($currentTicket) {
|
||||||
|
$success = $ticketModel->updateTicket([
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $currentTicket['title'],
|
||||||
|
'description' => $currentTicket['description'],
|
||||||
|
'category' => $currentTicket['category'],
|
||||||
|
'type' => $currentTicket['type'],
|
||||||
|
'status' => $parameters['status'],
|
||||||
|
'priority' => $currentTicket['priority']
|
||||||
|
], $operation['performed_by']);
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
||||||
|
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
$processed++;
|
||||||
|
} else {
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$failed++;
|
||||||
|
error_log("Bulk operation error for ticket $ticketId: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update operation status
|
||||||
|
$sql = "UPDATE bulk_operations SET status = 'completed', processed_tickets = ?, failed_tickets = ?,
|
||||||
|
completed_at = NOW() WHERE operation_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("iii", $processed, $failed, $operationId);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
return ['processed' => $processed, 'failed' => $failed];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bulk operation by ID
|
||||||
|
*
|
||||||
|
* @param int $operationId Operation ID
|
||||||
|
* @return array|null Operation record or null
|
||||||
|
*/
|
||||||
|
public function getOperationById($operationId) {
|
||||||
|
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $operationId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$operation = $result->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
return $operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bulk operations performed by a user
|
||||||
|
*
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @param int $limit Result limit
|
||||||
|
* @return array Array of operations
|
||||||
|
*/
|
||||||
|
public function getOperationsByUser($userId, $limit = 50) {
|
||||||
|
$sql = "SELECT * FROM bulk_operations WHERE performed_by = ?
|
||||||
|
ORDER BY created_at DESC LIMIT ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("ii", $userId, $limit);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$operations = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
if ($row['parameters']) {
|
||||||
|
$row['parameters'] = json_decode($row['parameters'], true);
|
||||||
|
}
|
||||||
|
$operations[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return $operations;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,11 @@ class CommentModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getCommentsByTicketId($ticketId) {
|
public function getCommentsByTicketId($ticketId) {
|
||||||
$sql = "SELECT * FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at DESC";
|
$sql = "SELECT tc.*, u.display_name, u.username
|
||||||
|
FROM ticket_comments tc
|
||||||
|
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||||
|
WHERE tc.ticket_id = ?
|
||||||
|
ORDER BY tc.created_at DESC";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar
|
$stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
@@ -15,19 +19,25 @@ class CommentModel {
|
|||||||
|
|
||||||
$comments = [];
|
$comments = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
// Use display_name from users table if available, fallback to user_name field
|
||||||
|
if (!empty($row['display_name'])) {
|
||||||
|
$row['display_name_formatted'] = $row['display_name'];
|
||||||
|
} else {
|
||||||
|
$row['display_name_formatted'] = $row['user_name'] ?? 'Unknown User';
|
||||||
|
}
|
||||||
$comments[] = $row;
|
$comments[] = $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $comments;
|
return $comments;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addComment($ticketId, $commentData) {
|
public function addComment($ticketId, $commentData, $userId = null) {
|
||||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled)
|
||||||
VALUES (?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|
||||||
// Set default username
|
// Set default username (kept for backward compatibility)
|
||||||
$username = $commentData['user_name'] ?? 'User';
|
$username = $commentData['user_name'] ?? 'User';
|
||||||
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
|
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
|
||||||
|
|
||||||
@@ -35,16 +45,20 @@ class CommentModel {
|
|||||||
$commentText = $commentData['comment_text'];
|
$commentText = $commentData['comment_text'];
|
||||||
|
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"sssi",
|
"sissi",
|
||||||
$ticketId,
|
$ticketId,
|
||||||
|
$userId,
|
||||||
$username,
|
$username,
|
||||||
$commentText,
|
$commentText,
|
||||||
$markdownEnabled
|
$markdownEnabled
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
|
$commentId = $this->conn->insert_id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
'comment_id' => $commentId,
|
||||||
'user_name' => $username,
|
'user_name' => $username,
|
||||||
'created_at' => date('M d, Y H:i'),
|
'created_at' => date('M d, Y H:i'),
|
||||||
'markdown_enabled' => $markdownEnabled,
|
'markdown_enabled' => $markdownEnabled,
|
||||||
|
|||||||
189
models/SavedFiltersModel.php
Normal file
189
models/SavedFiltersModel.php
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* SavedFiltersModel
|
||||||
|
* Handles saving, loading, and managing user's custom search filters
|
||||||
|
*/
|
||||||
|
class SavedFiltersModel {
|
||||||
|
private $conn;
|
||||||
|
|
||||||
|
public function __construct($conn) {
|
||||||
|
$this->conn = $conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all saved filters for a user
|
||||||
|
*/
|
||||||
|
public function getUserFilters($userId) {
|
||||||
|
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default, created_at, updated_at
|
||||||
|
FROM saved_filters
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY is_default DESC, filter_name ASC";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$filters = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$row['filter_criteria'] = json_decode($row['filter_criteria'], true);
|
||||||
|
$filters[] = $row;
|
||||||
|
}
|
||||||
|
return $filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific saved filter
|
||||||
|
*/
|
||||||
|
public function getFilter($filterId, $userId) {
|
||||||
|
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default
|
||||||
|
FROM saved_filters
|
||||||
|
WHERE filter_id = ? AND user_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("ii", $filterId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
if ($row = $result->fetch_assoc()) {
|
||||||
|
$row['filter_criteria'] = json_decode($row['filter_criteria'], true);
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a new filter
|
||||||
|
*/
|
||||||
|
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) {
|
||||||
|
// If this is set as default, unset all other defaults for this user
|
||||||
|
if ($isDefault) {
|
||||||
|
$this->clearDefaultFilters($userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "INSERT INTO saved_filters (user_id, filter_name, filter_criteria, is_default)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
filter_criteria = VALUES(filter_criteria),
|
||||||
|
is_default = VALUES(is_default),
|
||||||
|
updated_at = CURRENT_TIMESTAMP";
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$criteriaJson = json_encode($filterCriteria);
|
||||||
|
$stmt->bind_param("issi", $userId, $filterName, $criteriaJson, $isDefault);
|
||||||
|
|
||||||
|
if ($stmt->execute()) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'filter_id' => $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return ['success' => false, 'error' => $this->conn->error];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing filter
|
||||||
|
*/
|
||||||
|
public function updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault = false) {
|
||||||
|
// Verify ownership
|
||||||
|
$existing = $this->getFilter($filterId, $userId);
|
||||||
|
if (!$existing) {
|
||||||
|
return ['success' => false, 'error' => 'Filter not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is set as default, unset all other defaults for this user
|
||||||
|
if ($isDefault) {
|
||||||
|
$this->clearDefaultFilters($userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "UPDATE saved_filters
|
||||||
|
SET filter_name = ?, filter_criteria = ?, is_default = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE filter_id = ? AND user_id = ?";
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$criteriaJson = json_encode($filterCriteria);
|
||||||
|
$stmt->bind_param("ssiii", $filterName, $criteriaJson, $isDefault, $filterId, $userId);
|
||||||
|
|
||||||
|
if ($stmt->execute()) {
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
return ['success' => false, 'error' => $this->conn->error];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a saved filter
|
||||||
|
*/
|
||||||
|
public function deleteFilter($filterId, $userId) {
|
||||||
|
$sql = "DELETE FROM saved_filters WHERE filter_id = ? AND user_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("ii", $filterId, $userId);
|
||||||
|
|
||||||
|
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
return ['success' => false, 'error' => 'Filter not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a filter as default
|
||||||
|
*/
|
||||||
|
public function setDefaultFilter($filterId, $userId) {
|
||||||
|
// First, clear all defaults
|
||||||
|
$this->clearDefaultFilters($userId);
|
||||||
|
|
||||||
|
// Then set this one as default
|
||||||
|
$sql = "UPDATE saved_filters SET is_default = 1 WHERE filter_id = ? AND user_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("ii", $filterId, $userId);
|
||||||
|
|
||||||
|
if ($stmt->execute()) {
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
return ['success' => false, 'error' => $this->conn->error];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default filter for a user
|
||||||
|
*/
|
||||||
|
public function getDefaultFilter($userId) {
|
||||||
|
$sql = "SELECT filter_id, filter_name, filter_criteria
|
||||||
|
FROM saved_filters
|
||||||
|
WHERE user_id = ? AND is_default = 1
|
||||||
|
LIMIT 1";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
if ($row = $result->fetch_assoc()) {
|
||||||
|
$row['filter_criteria'] = json_decode($row['filter_criteria'], true);
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all default filters for a user (helper method)
|
||||||
|
*/
|
||||||
|
private function clearDefaultFilters($userId) {
|
||||||
|
$sql = "UPDATE saved_filters SET is_default = 0 WHERE user_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filter ID by name (helper method)
|
||||||
|
*/
|
||||||
|
private function getFilterIdByName($userId, $filterName) {
|
||||||
|
$sql = "SELECT filter_id FROM saved_filters WHERE user_id = ? AND filter_name = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("is", $userId, $filterName);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
if ($row = $result->fetch_assoc()) {
|
||||||
|
return $row['filter_id'];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
120
models/TemplateModel.php
Normal file
120
models/TemplateModel.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* TemplateModel - Handles ticket template operations
|
||||||
|
*/
|
||||||
|
class TemplateModel {
|
||||||
|
private $conn;
|
||||||
|
|
||||||
|
public function __construct($conn) {
|
||||||
|
$this->conn = $conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active templates
|
||||||
|
*
|
||||||
|
* @return array Array of template records
|
||||||
|
*/
|
||||||
|
public function getAllTemplates() {
|
||||||
|
$sql = "SELECT * FROM ticket_templates WHERE is_active = TRUE ORDER BY template_name";
|
||||||
|
$result = $this->conn->query($sql);
|
||||||
|
|
||||||
|
$templates = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$templates[] = $row;
|
||||||
|
}
|
||||||
|
return $templates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get template by ID
|
||||||
|
*
|
||||||
|
* @param int $templateId Template ID
|
||||||
|
* @return array|null Template record or null if not found
|
||||||
|
*/
|
||||||
|
public function getTemplateById($templateId) {
|
||||||
|
$sql = "SELECT * FROM ticket_templates WHERE template_id = ? AND is_active = TRUE";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $templateId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$template = $result->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new template
|
||||||
|
*
|
||||||
|
* @param array $data Template data
|
||||||
|
* @param int $createdBy User ID creating the template
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function createTemplate($data, $createdBy) {
|
||||||
|
$sql = "INSERT INTO ticket_templates (template_name, title_template, description_template,
|
||||||
|
category, type, default_priority, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("sssssii",
|
||||||
|
$data['template_name'],
|
||||||
|
$data['title_template'],
|
||||||
|
$data['description_template'],
|
||||||
|
$data['category'],
|
||||||
|
$data['type'],
|
||||||
|
$data['default_priority'],
|
||||||
|
$createdBy
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing template
|
||||||
|
*
|
||||||
|
* @param int $templateId Template ID
|
||||||
|
* @param array $data Template data to update
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function updateTemplate($templateId, $data) {
|
||||||
|
$sql = "UPDATE ticket_templates SET
|
||||||
|
template_name = ?,
|
||||||
|
title_template = ?,
|
||||||
|
description_template = ?,
|
||||||
|
category = ?,
|
||||||
|
type = ?,
|
||||||
|
default_priority = ?
|
||||||
|
WHERE template_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("ssssiii",
|
||||||
|
$data['template_name'],
|
||||||
|
$data['title_template'],
|
||||||
|
$data['description_template'],
|
||||||
|
$data['category'],
|
||||||
|
$data['type'],
|
||||||
|
$data['default_priority'],
|
||||||
|
$templateId
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate a template (soft delete)
|
||||||
|
*
|
||||||
|
* @param int $templateId Template ID
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function deactivateTemplate($templateId) {
|
||||||
|
$sql = "UPDATE ticket_templates SET is_active = FALSE WHERE template_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $templateId);
|
||||||
|
|
||||||
|
$result = $stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,18 @@ class TicketModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getTicketById($id) {
|
public function getTicketById($id) {
|
||||||
$sql = "SELECT * FROM tickets WHERE ticket_id = ?";
|
$sql = "SELECT t.*,
|
||||||
|
u_created.username as creator_username,
|
||||||
|
u_created.display_name as creator_display_name,
|
||||||
|
u_updated.username as updater_username,
|
||||||
|
u_updated.display_name as updater_display_name,
|
||||||
|
u_assigned.username as assigned_username,
|
||||||
|
u_assigned.display_name as assigned_display_name
|
||||||
|
FROM tickets t
|
||||||
|
LEFT JOIN users u_created ON t.created_by = u_created.user_id
|
||||||
|
LEFT JOIN users u_updated ON t.updated_by = u_updated.user_id
|
||||||
|
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
||||||
|
WHERE t.ticket_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $id);
|
$stmt->bind_param("i", $id);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
@@ -35,7 +46,7 @@ class TicketModel {
|
|||||||
return $comments;
|
return $comments;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAllTickets($page = 1, $limit = 15, $status = 'Open', $sortColumn = 'ticket_id', $sortDirection = 'desc', $category = null, $type = null, $search = null) {
|
public function getAllTickets($page = 1, $limit = 15, $status = 'Open', $sortColumn = 'ticket_id', $sortDirection = 'desc', $category = null, $type = null, $search = null, $filters = []) {
|
||||||
// Calculate offset
|
// Calculate offset
|
||||||
$offset = ($page - 1) * $limit;
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
@@ -79,17 +90,84 @@ class TicketModel {
|
|||||||
$paramTypes .= 'sssss';
|
$paramTypes .= 'sssss';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advanced search filters
|
||||||
|
// Date range - created_at
|
||||||
|
if (!empty($filters['created_from'])) {
|
||||||
|
$whereConditions[] = "DATE(t.created_at) >= ?";
|
||||||
|
$params[] = $filters['created_from'];
|
||||||
|
$paramTypes .= 's';
|
||||||
|
}
|
||||||
|
if (!empty($filters['created_to'])) {
|
||||||
|
$whereConditions[] = "DATE(t.created_at) <= ?";
|
||||||
|
$params[] = $filters['created_to'];
|
||||||
|
$paramTypes .= 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date range - updated_at
|
||||||
|
if (!empty($filters['updated_from'])) {
|
||||||
|
$whereConditions[] = "DATE(t.updated_at) >= ?";
|
||||||
|
$params[] = $filters['updated_from'];
|
||||||
|
$paramTypes .= 's';
|
||||||
|
}
|
||||||
|
if (!empty($filters['updated_to'])) {
|
||||||
|
$whereConditions[] = "DATE(t.updated_at) <= ?";
|
||||||
|
$params[] = $filters['updated_to'];
|
||||||
|
$paramTypes .= 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority range
|
||||||
|
if (!empty($filters['priority_min'])) {
|
||||||
|
$whereConditions[] = "t.priority >= ?";
|
||||||
|
$params[] = (int)$filters['priority_min'];
|
||||||
|
$paramTypes .= 'i';
|
||||||
|
}
|
||||||
|
if (!empty($filters['priority_max'])) {
|
||||||
|
$whereConditions[] = "t.priority <= ?";
|
||||||
|
$params[] = (int)$filters['priority_max'];
|
||||||
|
$paramTypes .= 'i';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created by user
|
||||||
|
if (!empty($filters['created_by'])) {
|
||||||
|
$whereConditions[] = "t.created_by = ?";
|
||||||
|
$params[] = (int)$filters['created_by'];
|
||||||
|
$paramTypes .= 'i';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assigned to user (including unassigned option)
|
||||||
|
if (!empty($filters['assigned_to'])) {
|
||||||
|
if ($filters['assigned_to'] === 'unassigned') {
|
||||||
|
$whereConditions[] = "t.assigned_to IS NULL";
|
||||||
|
} else {
|
||||||
|
$whereConditions[] = "t.assigned_to = ?";
|
||||||
|
$params[] = (int)$filters['assigned_to'];
|
||||||
|
$paramTypes .= 'i';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$whereClause = '';
|
$whereClause = '';
|
||||||
if (!empty($whereConditions)) {
|
if (!empty($whereConditions)) {
|
||||||
$whereClause = 'WHERE ' . implode(' AND ', $whereConditions);
|
$whereClause = 'WHERE ' . implode(' AND ', $whereConditions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate sort column to prevent SQL injection
|
// Validate sort column to prevent SQL injection
|
||||||
$allowedColumns = ['ticket_id', 'title', 'status', 'priority', 'category', 'type', 'created_at', 'updated_at'];
|
$allowedColumns = ['ticket_id', 'title', 'status', 'priority', 'category', 'type', 'created_at', 'updated_at', 'created_by', 'assigned_to'];
|
||||||
if (!in_array($sortColumn, $allowedColumns)) {
|
if (!in_array($sortColumn, $allowedColumns)) {
|
||||||
$sortColumn = 'ticket_id';
|
$sortColumn = 'ticket_id';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map column names to actual sort expressions
|
||||||
|
// For user columns, sort by display name with NULL handling for unassigned
|
||||||
|
$sortExpression = $sortColumn;
|
||||||
|
if ($sortColumn === 'created_by') {
|
||||||
|
$sortExpression = "COALESCE(u_created.display_name, u_created.username, 'System')";
|
||||||
|
} elseif ($sortColumn === 'assigned_to') {
|
||||||
|
// Put unassigned (NULL) at the end regardless of sort direction
|
||||||
|
$sortExpression = "CASE WHEN t.assigned_to IS NULL THEN 1 ELSE 0 END, COALESCE(u_assigned.display_name, u_assigned.username)";
|
||||||
|
} else {
|
||||||
|
$sortExpression = "t.$sortColumn";
|
||||||
|
}
|
||||||
|
|
||||||
// Validate sort direction
|
// Validate sort direction
|
||||||
$sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC';
|
$sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
@@ -105,8 +183,18 @@ class TicketModel {
|
|||||||
$totalResult = $countStmt->get_result();
|
$totalResult = $countStmt->get_result();
|
||||||
$totalTickets = $totalResult->fetch_assoc()['total'];
|
$totalTickets = $totalResult->fetch_assoc()['total'];
|
||||||
|
|
||||||
// Get tickets with pagination
|
// Get tickets with pagination and creator info
|
||||||
$sql = "SELECT * FROM tickets $whereClause ORDER BY $sortColumn $sortDirection LIMIT ? OFFSET ?";
|
$sql = "SELECT t.*,
|
||||||
|
u_created.username as creator_username,
|
||||||
|
u_created.display_name as creator_display_name,
|
||||||
|
u_assigned.username as assigned_username,
|
||||||
|
u_assigned.display_name as assigned_display_name
|
||||||
|
FROM tickets t
|
||||||
|
LEFT JOIN users u_created ON t.created_by = u_created.user_id
|
||||||
|
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
||||||
|
$whereClause
|
||||||
|
ORDER BY $sortExpression $sortDirection
|
||||||
|
LIMIT ? OFFSET ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|
||||||
// Add limit and offset parameters
|
// Add limit and offset parameters
|
||||||
@@ -134,7 +222,7 @@ class TicketModel {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateTicket($ticketData) {
|
public function updateTicket($ticketData, $updatedBy = null) {
|
||||||
// Debug function
|
// Debug function
|
||||||
$debug = function($message, $data = null) {
|
$debug = function($message, $data = null) {
|
||||||
$log_message = date('Y-m-d H:i:s') . " - [Model] " . $message;
|
$log_message = date('Y-m-d H:i:s') . " - [Model] " . $message;
|
||||||
@@ -154,6 +242,7 @@ class TicketModel {
|
|||||||
description = ?,
|
description = ?,
|
||||||
category = ?,
|
category = ?,
|
||||||
type = ?,
|
type = ?,
|
||||||
|
updated_by = ?,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE ticket_id = ?";
|
WHERE ticket_id = ?";
|
||||||
|
|
||||||
@@ -168,13 +257,14 @@ class TicketModel {
|
|||||||
|
|
||||||
$debug("Binding parameters");
|
$debug("Binding parameters");
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"sissssi",
|
"sissssii",
|
||||||
$ticketData['title'],
|
$ticketData['title'],
|
||||||
$ticketData['priority'],
|
$ticketData['priority'],
|
||||||
$ticketData['status'],
|
$ticketData['status'],
|
||||||
$ticketData['description'],
|
$ticketData['description'],
|
||||||
$ticketData['category'],
|
$ticketData['category'],
|
||||||
$ticketData['type'],
|
$ticketData['type'],
|
||||||
|
$updatedBy,
|
||||||
$ticketData['ticket_id']
|
$ticketData['ticket_id']
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -195,12 +285,12 @@ class TicketModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createTicket($ticketData) {
|
public function createTicket($ticketData, $createdBy = null) {
|
||||||
// Generate ticket ID (9-digit format with leading zeros)
|
// Generate ticket ID (9-digit format with leading zeros)
|
||||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
||||||
|
|
||||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type)
|
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|
||||||
@@ -211,14 +301,15 @@ class TicketModel {
|
|||||||
$type = $ticketData['type'] ?? 'Issue';
|
$type = $ticketData['type'] ?? 'Issue';
|
||||||
|
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"sssssss",
|
"sssssssi",
|
||||||
$ticket_id,
|
$ticket_id,
|
||||||
$ticketData['title'],
|
$ticketData['title'],
|
||||||
$ticketData['description'],
|
$ticketData['description'],
|
||||||
$status,
|
$status,
|
||||||
$priority,
|
$priority,
|
||||||
$category,
|
$category,
|
||||||
$type
|
$type,
|
||||||
|
$createdBy
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
@@ -265,4 +356,83 @@ class TicketModel {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign ticket to a user
|
||||||
|
*
|
||||||
|
* @param int $ticketId Ticket ID
|
||||||
|
* @param int $userId User ID to assign to
|
||||||
|
* @param int $assignedBy User ID performing the assignment
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function assignTicket($ticketId, $userId, $assignedBy) {
|
||||||
|
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
|
||||||
|
$result = $stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unassign ticket (set assigned_to to NULL)
|
||||||
|
*
|
||||||
|
* @param int $ticketId Ticket ID
|
||||||
|
* @param int $updatedBy User ID performing the unassignment
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function unassignTicket($ticketId, $updatedBy) {
|
||||||
|
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("ii", $updatedBy, $ticketId);
|
||||||
|
$result = $stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get multiple tickets by IDs in a single query (batch loading)
|
||||||
|
* Eliminates N+1 query problem in bulk operations
|
||||||
|
*
|
||||||
|
* @param array $ticketIds Array of ticket IDs
|
||||||
|
* @return array Associative array keyed by ticket_id
|
||||||
|
*/
|
||||||
|
public function getTicketsByIds($ticketIds) {
|
||||||
|
if (empty($ticketIds)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize ticket IDs
|
||||||
|
$ticketIds = array_map('intval', $ticketIds);
|
||||||
|
|
||||||
|
// Create placeholders for IN clause
|
||||||
|
$placeholders = str_repeat('?,', count($ticketIds) - 1) . '?';
|
||||||
|
|
||||||
|
$sql = "SELECT t.*,
|
||||||
|
u_created.username as creator_username,
|
||||||
|
u_created.display_name as creator_display_name,
|
||||||
|
u_updated.username as updater_username,
|
||||||
|
u_updated.display_name as updater_display_name,
|
||||||
|
u_assigned.username as assigned_username,
|
||||||
|
u_assigned.display_name as assigned_display_name
|
||||||
|
FROM tickets t
|
||||||
|
LEFT JOIN users u_created ON t.created_by = u_created.user_id
|
||||||
|
LEFT JOIN users u_updated ON t.updated_by = u_updated.user_id
|
||||||
|
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
||||||
|
WHERE t.ticket_id IN ($placeholders)";
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$types = str_repeat('i', count($ticketIds));
|
||||||
|
$stmt->bind_param($types, ...$ticketIds);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$tickets = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$tickets[$row['ticket_id']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return $tickets;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
269
models/UserModel.php
Normal file
269
models/UserModel.php
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* UserModel - Handles user authentication and management
|
||||||
|
*/
|
||||||
|
class UserModel {
|
||||||
|
private $conn;
|
||||||
|
private static $userCache = []; // ['key' => ['data' => ..., 'expires' => timestamp]]
|
||||||
|
private static $cacheTTL = 300; // 5 minutes
|
||||||
|
|
||||||
|
public function __construct($conn) {
|
||||||
|
$this->conn = $conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached user data if not expired
|
||||||
|
*/
|
||||||
|
private static function getCached($key) {
|
||||||
|
if (isset(self::$userCache[$key])) {
|
||||||
|
$cached = self::$userCache[$key];
|
||||||
|
if ($cached['expires'] > time()) {
|
||||||
|
return $cached['data'];
|
||||||
|
}
|
||||||
|
// Expired - remove from cache
|
||||||
|
unset(self::$userCache[$key]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store user data in cache with expiration
|
||||||
|
*/
|
||||||
|
private static function setCached($key, $data) {
|
||||||
|
self::$userCache[$key] = [
|
||||||
|
'data' => $data,
|
||||||
|
'expires' => time() + self::$cacheTTL
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate specific user cache entry
|
||||||
|
*/
|
||||||
|
public static function invalidateCache($userId = null, $username = null) {
|
||||||
|
if ($userId !== null) {
|
||||||
|
unset(self::$userCache["user_id_$userId"]);
|
||||||
|
}
|
||||||
|
if ($username !== null) {
|
||||||
|
unset(self::$userCache["user_$username"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync user from Authelia headers (create or update)
|
||||||
|
*
|
||||||
|
* @param string $username Username from Remote-User header
|
||||||
|
* @param string $displayName Display name from Remote-Name header
|
||||||
|
* @param string $email Email from Remote-Email header
|
||||||
|
* @param string $groups Comma-separated groups from Remote-Groups header
|
||||||
|
* @return array User data array
|
||||||
|
*/
|
||||||
|
public function syncUserFromAuthelia($username, $displayName = '', $email = '', $groups = '') {
|
||||||
|
// Check cache first
|
||||||
|
$cacheKey = "user_$username";
|
||||||
|
$cached = self::getCached($cacheKey);
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if user is admin based on groups
|
||||||
|
$isAdmin = $this->checkAdminStatus($groups);
|
||||||
|
|
||||||
|
// Try to find existing user
|
||||||
|
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
|
||||||
|
$stmt->bind_param("s", $username);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
if ($result->num_rows > 0) {
|
||||||
|
// Update existing user
|
||||||
|
$user = $result->fetch_assoc();
|
||||||
|
|
||||||
|
$updateStmt = $this->conn->prepare(
|
||||||
|
"UPDATE users SET display_name = ?, email = ?, groups = ?, is_admin = ?, last_login = NOW() WHERE username = ?"
|
||||||
|
);
|
||||||
|
$updateStmt->bind_param("sssis", $displayName, $email, $groups, $isAdmin, $username);
|
||||||
|
$updateStmt->execute();
|
||||||
|
$updateStmt->close();
|
||||||
|
|
||||||
|
// Refresh user data
|
||||||
|
$user['display_name'] = $displayName;
|
||||||
|
$user['email'] = $email;
|
||||||
|
$user['groups'] = $groups;
|
||||||
|
$user['is_admin'] = $isAdmin;
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
$insertStmt = $this->conn->prepare(
|
||||||
|
"INSERT INTO users (username, display_name, email, groups, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
|
||||||
|
);
|
||||||
|
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
|
||||||
|
$insertStmt->execute();
|
||||||
|
|
||||||
|
$userId = $this->conn->insert_id;
|
||||||
|
$insertStmt->close();
|
||||||
|
|
||||||
|
// Get the newly created user
|
||||||
|
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?");
|
||||||
|
$stmt->bind_param("i", $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$user = $result->fetch_assoc();
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Cache user with TTL
|
||||||
|
self::setCached($cacheKey, $user);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get system user (for hwmonDaemon)
|
||||||
|
*
|
||||||
|
* @return array|null System user data or null if not found
|
||||||
|
*/
|
||||||
|
public function getSystemUser() {
|
||||||
|
// Check cache first
|
||||||
|
$cached = self::getCached('system');
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = 'system'");
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
if ($result->num_rows > 0) {
|
||||||
|
$user = $result->fetch_assoc();
|
||||||
|
self::setCached('system', $user);
|
||||||
|
$stmt->close();
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user by ID
|
||||||
|
*
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @return array|null User data or null if not found
|
||||||
|
*/
|
||||||
|
public function getUserById($userId) {
|
||||||
|
// Check cache first
|
||||||
|
$cacheKey = "user_id_$userId";
|
||||||
|
$cached = self::getCached($cacheKey);
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?");
|
||||||
|
$stmt->bind_param("i", $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
if ($result->num_rows > 0) {
|
||||||
|
$user = $result->fetch_assoc();
|
||||||
|
self::setCached($cacheKey, $user);
|
||||||
|
$stmt->close();
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user by username
|
||||||
|
*
|
||||||
|
* @param string $username Username
|
||||||
|
* @return array|null User data or null if not found
|
||||||
|
*/
|
||||||
|
public function getUserByUsername($username) {
|
||||||
|
// Check cache first
|
||||||
|
$cacheKey = "user_$username";
|
||||||
|
$cached = self::getCached($cacheKey);
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
|
||||||
|
$stmt->bind_param("s", $username);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
if ($result->num_rows > 0) {
|
||||||
|
$user = $result->fetch_assoc();
|
||||||
|
self::setCached($cacheKey, $user);
|
||||||
|
$stmt->close();
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has admin privileges based on groups
|
||||||
|
*
|
||||||
|
* @param string $groups Comma-separated group names
|
||||||
|
* @return bool True if user is in admin group
|
||||||
|
*/
|
||||||
|
private function checkAdminStatus($groups) {
|
||||||
|
if (empty($groups)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split groups by comma and check for 'admin' group
|
||||||
|
$groupArray = array_map('trim', explode(',', strtolower($groups)));
|
||||||
|
return in_array('admin', $groupArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is admin
|
||||||
|
*
|
||||||
|
* @param array $user User data array
|
||||||
|
* @return bool True if user is admin
|
||||||
|
*/
|
||||||
|
public function isAdmin($user) {
|
||||||
|
return isset($user['is_admin']) && $user['is_admin'] == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has required group membership
|
||||||
|
*
|
||||||
|
* @param array $user User data array
|
||||||
|
* @param array $requiredGroups Array of required group names
|
||||||
|
* @return bool True if user is in at least one required group
|
||||||
|
*/
|
||||||
|
public function hasGroupAccess($user, $requiredGroups = ['admin', 'employee']) {
|
||||||
|
if (empty($user['groups'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userGroups = array_map('trim', explode(',', strtolower($user['groups'])));
|
||||||
|
$requiredGroups = array_map('strtolower', $requiredGroups);
|
||||||
|
|
||||||
|
return !empty(array_intersect($userGroups, $requiredGroups));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all users (for admin panel)
|
||||||
|
*
|
||||||
|
* @return array Array of user records
|
||||||
|
*/
|
||||||
|
public function getAllUsers() {
|
||||||
|
$stmt = $this->conn->prepare("SELECT * FROM users ORDER BY created_at DESC");
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$users = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$users[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return $users;
|
||||||
|
}
|
||||||
|
}
|
||||||
98
models/UserPreferencesModel.php
Normal file
98
models/UserPreferencesModel.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* UserPreferencesModel
|
||||||
|
* Handles user-specific preferences and settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
class UserPreferencesModel {
|
||||||
|
private $conn;
|
||||||
|
|
||||||
|
public function __construct($conn) {
|
||||||
|
$this->conn = $conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all preferences for a user
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @return array Associative array of preference_key => preference_value
|
||||||
|
*/
|
||||||
|
public function getUserPreferences($userId) {
|
||||||
|
$sql = "SELECT preference_key, preference_value
|
||||||
|
FROM user_preferences
|
||||||
|
WHERE user_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$prefs = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$prefs[$row['preference_key']] = $row['preference_value'];
|
||||||
|
}
|
||||||
|
return $prefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or update a preference for a user
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @param string $key Preference key
|
||||||
|
* @param string $value Preference value
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function setPreference($userId, $key, $value) {
|
||||||
|
$sql = "INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE preference_value = VALUES(preference_value)";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("iss", $userId, $key, $value);
|
||||||
|
return $stmt->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single preference value for a user
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @param string $key Preference key
|
||||||
|
* @param mixed $default Default value if preference doesn't exist
|
||||||
|
* @return mixed Preference value or default
|
||||||
|
*/
|
||||||
|
public function getPreference($userId, $key, $default = null) {
|
||||||
|
$sql = "SELECT preference_value FROM user_preferences
|
||||||
|
WHERE user_id = ? AND preference_key = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("is", $userId, $key);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
if ($row = $result->fetch_assoc()) {
|
||||||
|
return $row['preference_value'];
|
||||||
|
}
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a preference for a user
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @param string $key Preference key
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function deletePreference($userId, $key) {
|
||||||
|
$sql = "DELETE FROM user_preferences
|
||||||
|
WHERE user_id = ? AND preference_key = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("is", $userId, $key);
|
||||||
|
return $stmt->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all preferences for a user
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function deleteAllPreferences($userId) {
|
||||||
|
$sql = "DELETE FROM user_preferences WHERE user_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $userId);
|
||||||
|
return $stmt->execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
117
models/WorkflowModel.php
Normal file
117
models/WorkflowModel.php
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* WorkflowModel - Handles status transition workflows and validation
|
||||||
|
*/
|
||||||
|
class WorkflowModel {
|
||||||
|
private $conn;
|
||||||
|
|
||||||
|
public function __construct($conn) {
|
||||||
|
$this->conn = $conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get allowed status transitions for a given status
|
||||||
|
*
|
||||||
|
* @param string $currentStatus Current ticket status
|
||||||
|
* @return array Array of allowed transitions with requirements
|
||||||
|
*/
|
||||||
|
public function getAllowedTransitions($currentStatus) {
|
||||||
|
$sql = "SELECT to_status, requires_comment, requires_admin
|
||||||
|
FROM status_transitions
|
||||||
|
WHERE from_status = ? AND is_active = TRUE";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("s", $currentStatus);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$transitions = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$transitions[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return $transitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a status transition is allowed
|
||||||
|
*
|
||||||
|
* @param string $fromStatus Current status
|
||||||
|
* @param string $toStatus Desired status
|
||||||
|
* @param bool $isAdmin Whether user is admin
|
||||||
|
* @return bool True if transition is allowed
|
||||||
|
*/
|
||||||
|
public function isTransitionAllowed($fromStatus, $toStatus, $isAdmin = false) {
|
||||||
|
// Allow same status (no change)
|
||||||
|
if ($fromStatus === $toStatus) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT requires_admin FROM status_transitions
|
||||||
|
WHERE from_status = ? AND to_status = ? AND is_active = TRUE";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("ss", $fromStatus, $toStatus);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
if ($result->num_rows === 0) {
|
||||||
|
$stmt->close();
|
||||||
|
return false; // Transition not defined
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
if ($row['requires_admin'] && !$isAdmin) {
|
||||||
|
return false; // Admin required
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all possible statuses from transitions table
|
||||||
|
*
|
||||||
|
* @return array Array of unique status values
|
||||||
|
*/
|
||||||
|
public function getAllStatuses() {
|
||||||
|
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT to_status as status FROM status_transitions
|
||||||
|
ORDER BY status";
|
||||||
|
$result = $this->conn->query($sql);
|
||||||
|
|
||||||
|
$statuses = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$statuses[] = $row['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get transition requirements
|
||||||
|
*
|
||||||
|
* @param string $fromStatus Current status
|
||||||
|
* @param string $toStatus Desired status
|
||||||
|
* @return array|null Transition requirements or null if not found
|
||||||
|
*/
|
||||||
|
public function getTransitionRequirements($fromStatus, $toStatus) {
|
||||||
|
$sql = "SELECT requires_comment, requires_admin
|
||||||
|
FROM status_transitions
|
||||||
|
WHERE from_status = ? AND to_status = ? AND is_active = TRUE";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("ss", $fromStatus, $toStatus);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
if ($result->num_rows === 0) {
|
||||||
|
$stmt->close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
tinker_tickets_react/.gitignore
vendored
24
tinker_tickets_react/.gitignore
vendored
@@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# React + TypeScript + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
|
|
||||||
|
|
||||||
Note: This will impact Vite dev & build performances.
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import js from '@eslint/js'
|
|
||||||
import globals from 'globals'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
|
||||||
tseslint.configs.recommended,
|
|
||||||
reactHooks.configs.flat.recommended,
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Tinker Tickets React</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
3302
tinker_tickets_react/package-lock.json
generated
3302
tinker_tickets_react/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "tinker_tickets_react",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"start": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"marked": "^17.0.1",
|
|
||||||
"react": "^19.2.0",
|
|
||||||
"react-dom": "^19.2.0",
|
|
||||||
"react-router-dom": "^7.9.6"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.39.1",
|
|
||||||
"@types/node": "^24.10.1",
|
|
||||||
"@types/react": "^19.2.5",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
|
||||||
"eslint": "^9.39.1",
|
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
|
||||||
"globals": "^16.5.0",
|
|
||||||
"typescript": "~5.9.3",
|
|
||||||
"typescript-eslint": "^8.46.4",
|
|
||||||
"vite": "^7.2.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#root {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 6em;
|
|
||||||
padding: 1.5em;
|
|
||||||
will-change: filter;
|
|
||||||
transition: filter 300ms;
|
|
||||||
}
|
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
|
||||||
}
|
|
||||||
.logo.react:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logo-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
a:nth-of-type(2) .logo {
|
|
||||||
animation: logo-spin infinite 20s linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
|
||||||
import DashboardView from "./Components/DashboardView/DashboardView";
|
|
||||||
import TicketView from "./Components/TicketView/TicketView";
|
|
||||||
import CreateTicket from "./Components/CreateTicket/CreateTicket";
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<BrowserRouter>
|
|
||||||
<Routes>
|
|
||||||
{/* Dashboard List */}
|
|
||||||
<Route path="/" element={<DashboardView />} />
|
|
||||||
|
|
||||||
{/* View a Ticket */}
|
|
||||||
<Route path="/ticket/:id" element={<TicketView />} />
|
|
||||||
|
|
||||||
{/* Create a Ticket */}
|
|
||||||
<Route path="/ticket/create" element={<CreateTicket />} />
|
|
||||||
|
|
||||||
{/* 404 Fallback */}
|
|
||||||
<Route
|
|
||||||
path="*"
|
|
||||||
element={
|
|
||||||
<div style={{ padding: "2rem", fontSize: "1.3rem" }}>
|
|
||||||
<strong>404</strong> — Page not found
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { marked } from "marked";
|
|
||||||
import type { CommentData } from "../../types/comments";
|
|
||||||
|
|
||||||
interface CommentFormProps {
|
|
||||||
onAdd: (comment: CommentData) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommentForm: React.FC<CommentFormProps> = ({ onAdd }) => {
|
|
||||||
const [text, setText] = useState<string>("");
|
|
||||||
const [markdownEnabled, setMarkdownEnabled] = useState<boolean>(false);
|
|
||||||
const [preview, setPreview] = useState<boolean>(false);
|
|
||||||
|
|
||||||
function handleSubmit() {
|
|
||||||
if (!text.trim()) return;
|
|
||||||
|
|
||||||
const newComment: CommentData = {
|
|
||||||
user_name: "User",
|
|
||||||
comment_text: text,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
markdown_enabled: markdownEnabled,
|
|
||||||
};
|
|
||||||
|
|
||||||
onAdd(newComment);
|
|
||||||
setText("");
|
|
||||||
setPreview(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="comment-form">
|
|
||||||
<textarea
|
|
||||||
placeholder="Add a comment..."
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="comment-controls">
|
|
||||||
<label className="toggle-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={markdownEnabled}
|
|
||||||
onChange={() => setMarkdownEnabled((prev) => !prev)}
|
|
||||||
/>
|
|
||||||
Enable Markdown
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="toggle-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
disabled={!markdownEnabled}
|
|
||||||
checked={preview}
|
|
||||||
onChange={() => setPreview((prev) => !prev)}
|
|
||||||
/>
|
|
||||||
Preview Markdown
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button className="btn" onClick={handleSubmit}>
|
|
||||||
Add Comment
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{markdownEnabled && preview && (
|
|
||||||
<div
|
|
||||||
className="markdown-preview"
|
|
||||||
dangerouslySetInnerHTML={{ __html: marked(text) }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CommentForm;
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { marked } from "marked";
|
|
||||||
import type { CommentData } from "../../types/comments";
|
|
||||||
|
|
||||||
interface CommentItemProps {
|
|
||||||
comment: CommentData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommentItem: React.FC<CommentItemProps> = ({ comment }) => {
|
|
||||||
const { user_name, created_at, comment_text, markdown_enabled } = comment;
|
|
||||||
|
|
||||||
const formattedDate = new Date(created_at).toLocaleString();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="comment">
|
|
||||||
<div className="comment-header">
|
|
||||||
<span className="comment-user">{user_name}</span>
|
|
||||||
<span className="comment-date">{formattedDate}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="comment-text">
|
|
||||||
{markdown_enabled ? (
|
|
||||||
<div
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: marked(comment_text),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
comment_text.split("\n").map((line, i) => <div key={i}>{line}</div>)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CommentItem;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import CommentItem from "./CommentItem";
|
|
||||||
import type { CommentData } from "../../types/comments";
|
|
||||||
|
|
||||||
interface CommentListProps {
|
|
||||||
comments: CommentData[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommentList: React.FC<CommentListProps> = ({ comments }) => {
|
|
||||||
return (
|
|
||||||
<div className="comments-list">
|
|
||||||
{comments.map((c, idx) => (
|
|
||||||
<CommentItem key={idx} comment={c} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CommentList;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import CommentForm from "./CommentForm";
|
|
||||||
import CommentList from "./CommentList";
|
|
||||||
import type { CommentData } from "../../types/comments";
|
|
||||||
|
|
||||||
interface CommentsSectionProps {
|
|
||||||
comments: CommentData[];
|
|
||||||
setComments: React.Dispatch<React.SetStateAction<CommentData[]>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|
||||||
comments,
|
|
||||||
setComments,
|
|
||||||
}) => {
|
|
||||||
function handleAddComment(newComment: CommentData) {
|
|
||||||
setComments((prev) => [newComment, ...prev]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="comments-section">
|
|
||||||
<h2>Comments</h2>
|
|
||||||
|
|
||||||
<CommentForm onAdd={handleAddComment} />
|
|
||||||
|
|
||||||
<CommentList comments={comments} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CommentsSection;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import TicketForm from "./TicketForm";
|
|
||||||
|
|
||||||
const CreateTicket: React.FC = () => {
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ticket-container">
|
|
||||||
<div className="ticket-header">
|
|
||||||
<h2>Create New Ticket</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
|
||||||
|
|
||||||
<TicketForm onError={setError} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateTicket;
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import TicketFieldRow from "./TicketRow";
|
|
||||||
import TicketTextarea from "./TicketText";
|
|
||||||
import type { CreateTicketFormData } from "../../types/ticket";
|
|
||||||
|
|
||||||
interface TicketFormProps {
|
|
||||||
onError: (msg: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TicketForm: React.FC<TicketFormProps> = ({ onError }) => {
|
|
||||||
const [form, setForm] = useState<CreateTicketFormData>({
|
|
||||||
title: "",
|
|
||||||
status: "Open",
|
|
||||||
priority: "4",
|
|
||||||
category: "General",
|
|
||||||
type: "Issue",
|
|
||||||
description: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateField(field: keyof CreateTicketFormData, value: string) {
|
|
||||||
setForm(prev => ({ ...prev, [field]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!form.title.trim() || !form.description.trim()) {
|
|
||||||
onError("Title and description are required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Submitting:", form);
|
|
||||||
// Later: POST to Express/PHP
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="ticket-form" onSubmit={handleSubmit}>
|
|
||||||
<div className="ticket-details">
|
|
||||||
<div className="detail-group">
|
|
||||||
<label>Title</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.title}
|
|
||||||
onChange={e => updateField("title", e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TicketFieldRow form={form} updateField={updateField} />
|
|
||||||
|
|
||||||
<TicketTextarea
|
|
||||||
label="Description"
|
|
||||||
value={form.description}
|
|
||||||
onChange={value => updateField("description", value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ticket-footer">
|
|
||||||
<button type="submit" className="btn primary">Create Ticket</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn back-btn"
|
|
||||||
onClick={() => (window.location.href = "/")}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TicketForm;
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import TicketSelect from "./TicketSelect";
|
|
||||||
import type { CreateTicketFormData } from "../../types/ticket";
|
|
||||||
|
|
||||||
interface TicketRowProps {
|
|
||||||
form: CreateTicketFormData;
|
|
||||||
updateField: (field: keyof CreateTicketFormData, value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TicketRow: React.FC<TicketRowProps> = ({ form, updateField }) => {
|
|
||||||
return (
|
|
||||||
<div className="detail-group status-priority-row">
|
|
||||||
<TicketSelect
|
|
||||||
label="Status"
|
|
||||||
field="status"
|
|
||||||
value={form.status}
|
|
||||||
updateField={updateField}
|
|
||||||
options={[
|
|
||||||
{ value: "Open", label: "Open" },
|
|
||||||
{ value: "Closed", label: "Closed" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TicketSelect
|
|
||||||
label="Priority"
|
|
||||||
field="priority"
|
|
||||||
value={form.priority}
|
|
||||||
updateField={updateField}
|
|
||||||
options={[
|
|
||||||
{ value: "1", label: "P1 - Critical Impact" },
|
|
||||||
{ value: "2", label: "P2 - High Impact" },
|
|
||||||
{ value: "3", label: "P3 - Medium Impact" },
|
|
||||||
{ value: "4", label: "P4 - Low Impact" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TicketSelect
|
|
||||||
label="Category"
|
|
||||||
field="category"
|
|
||||||
value={form.category}
|
|
||||||
updateField={updateField}
|
|
||||||
options={[
|
|
||||||
{ value: "Hardware", label: "Hardware" },
|
|
||||||
{ value: "Software", label: "Software" },
|
|
||||||
{ value: "Network", label: "Network" },
|
|
||||||
{ value: "Security", label: "Security" },
|
|
||||||
{ value: "General", label: "General" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TicketSelect
|
|
||||||
label="Type"
|
|
||||||
field="type"
|
|
||||||
value={form.type}
|
|
||||||
updateField={updateField}
|
|
||||||
options={[
|
|
||||||
{ value: "Maintenance", label: "Maintenance" },
|
|
||||||
{ value: "Install", label: "Install" },
|
|
||||||
{ value: "Task", label: "Task" },
|
|
||||||
{ value: "Upgrade", label: "Upgrade" },
|
|
||||||
{ value: "Issue", label: "Issue" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TicketRow;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import type { SelectOption, CreateTicketFormData } from "../../types/ticket";
|
|
||||||
|
|
||||||
interface TicketSelectProps {
|
|
||||||
label: string;
|
|
||||||
field: keyof CreateTicketFormData;
|
|
||||||
value: string;
|
|
||||||
options: SelectOption[];
|
|
||||||
updateField: (field: keyof CreateTicketFormData, value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TicketSelect: React.FC<TicketSelectProps> = ({
|
|
||||||
label,
|
|
||||||
field,
|
|
||||||
value,
|
|
||||||
options,
|
|
||||||
updateField,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="detail-quarter">
|
|
||||||
<label>{label}</label>
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={e => updateField(field, e.target.value)}
|
|
||||||
>
|
|
||||||
{options.map(opt => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TicketSelect;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface TicketTextProps {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
required?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TicketText: React.FC<TicketTextProps> = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
required,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="detail-group full-width">
|
|
||||||
<label>{label}</label>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
rows={15}
|
|
||||||
value={value}
|
|
||||||
required={required}
|
|
||||||
onChange={e => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TicketText;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
const DashboardHeader: React.FC = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dashboard-header">
|
|
||||||
<h1>Tinker Tickets</h1>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn create-ticket"
|
|
||||||
onClick={() => navigate("/ticket/create")}
|
|
||||||
>
|
|
||||||
New Ticket
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DashboardHeader;
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import type { TicketListItem } from "../../types/ticket";
|
|
||||||
import ticketData from "../../mockData/tickets.json";
|
|
||||||
import DashboardHeader from "./DashboardHeader";
|
|
||||||
import Pagination from "./Pagination";
|
|
||||||
import SearchBar from "./SearchBar";
|
|
||||||
import TicketTable from "./TicketTable";
|
|
||||||
|
|
||||||
const DashboardView: React.FC = () => {
|
|
||||||
const [tickets] = useState<TicketListItem[]>(ticketData);
|
|
||||||
const [search, setSearch] = useState<string>("");
|
|
||||||
const [sortCol, setSortCol] = useState<keyof TicketListItem>("ticket_id");
|
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
|
||||||
const [page, setPage] = useState<number>(1);
|
|
||||||
|
|
||||||
const pageSize = 10;
|
|
||||||
|
|
||||||
const filtered = tickets.filter(
|
|
||||||
(t) =>
|
|
||||||
t.title.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
t.ticket_id.toLowerCase().includes(search.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
const sorted = [...filtered].sort((a, b) => {
|
|
||||||
const A = a[sortCol];
|
|
||||||
const B = b[sortCol];
|
|
||||||
|
|
||||||
if (A < B) return sortDir === "asc" ? -1 : 1;
|
|
||||||
if (A > B) return sortDir === "asc" ? 1 : -1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
|
||||||
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<DashboardHeader />
|
|
||||||
|
|
||||||
<SearchBar value={search} onChange={setSearch} />
|
|
||||||
|
|
||||||
<div className="table-controls">
|
|
||||||
<div>Total Tickets: {filtered.length}</div>
|
|
||||||
|
|
||||||
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TicketTable
|
|
||||||
tickets={paged}
|
|
||||||
sortCol={sortCol}
|
|
||||||
sortDir={sortDir}
|
|
||||||
onSort={(col) =>
|
|
||||||
col === sortCol
|
|
||||||
? setSortDir(sortDir === "asc" ? "desc" : "asc")
|
|
||||||
: (setSortCol(col), setSortDir("asc"))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DashboardView;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface PaginationProps {
|
|
||||||
page: number;
|
|
||||||
totalPages: number;
|
|
||||||
onChange: (p: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Pagination: React.FC<PaginationProps> = ({
|
|
||||||
page,
|
|
||||||
totalPages,
|
|
||||||
onChange,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="pagination">
|
|
||||||
<button disabled={page === 1} onClick={() => onChange(page - 1)}>
|
|
||||||
«
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((num) => (
|
|
||||||
<button
|
|
||||||
key={num}
|
|
||||||
className={page === num ? "active" : ""}
|
|
||||||
onClick={() => onChange(num)}
|
|
||||||
>
|
|
||||||
{num}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<button disabled={page === totalPages} onClick={() => onChange(page + 1)}>
|
|
||||||
»
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Pagination;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface SearchBarProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SearchBar: React.FC<SearchBarProps> = ({ value, onChange }) => {
|
|
||||||
return (
|
|
||||||
<div className="search-container">
|
|
||||||
<input
|
|
||||||
className="search-box"
|
|
||||||
type="text"
|
|
||||||
placeholder="Search tickets..."
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{value && (
|
|
||||||
<button className="clear-search-btn" onClick={() => onChange("")}>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SearchBar;
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import type { TicketListItem } from "../../types/ticket";
|
|
||||||
|
|
||||||
interface TicketTableProps {
|
|
||||||
tickets: TicketListItem[];
|
|
||||||
sortCol: keyof TicketListItem;
|
|
||||||
sortDir: "asc" | "desc";
|
|
||||||
onSort: (col: keyof TicketListItem) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: { key: keyof TicketListItem; label: string }[] = [
|
|
||||||
{ key: "ticket_id", label: "Ticket ID" },
|
|
||||||
{ key: "priority", label: "Priority" },
|
|
||||||
{ key: "title", label: "Title" },
|
|
||||||
{ key: "category", label: "Category" },
|
|
||||||
{ key: "type", label: "Type" },
|
|
||||||
{ key: "status", label: "Status" },
|
|
||||||
{ key: "created_at", label: "Created" },
|
|
||||||
{ key: "updated_at", label: "Updated" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TicketTable: React.FC<TicketTableProps> = ({
|
|
||||||
tickets,
|
|
||||||
sortCol,
|
|
||||||
sortDir,
|
|
||||||
onSort,
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{columns.map((col) => (
|
|
||||||
<th
|
|
||||||
key={col.key}
|
|
||||||
onClick={() => onSort(col.key)}
|
|
||||||
className={sortCol === col.key ? `sort-${sortDir}` : ""}
|
|
||||||
>
|
|
||||||
{col.label}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{tickets.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8}>No tickets found</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tickets.map((t) => (
|
|
||||||
<tr key={t.ticket_id} className={`priority-${t.priority}`}>
|
|
||||||
<td>
|
|
||||||
<a
|
|
||||||
className="ticket-link"
|
|
||||||
onClick={() => navigate(`/ticket/${t.ticket_id}`)}
|
|
||||||
>
|
|
||||||
{t.ticket_id}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td>{t.priority}</td>
|
|
||||||
<td>{t.title}</td>
|
|
||||||
<td>{t.category}</td>
|
|
||||||
<td>{t.type}</td>
|
|
||||||
<td>
|
|
||||||
<span className={`status-${t.status.replace(" ", "-")}`}>
|
|
||||||
{t.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{new Date(t.created_at).toLocaleString()}</td>
|
|
||||||
<td>{new Date(t.updated_at).toLocaleString()}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TicketTable;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface TicketDescriptionProps {
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TicketDescription: React.FC<TicketDescriptionProps> = ({
|
|
||||||
description,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="detail-group full-width">
|
|
||||||
<label>Description</label>
|
|
||||||
<textarea value={description} disabled />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TicketDescription;
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import type { TicketData } from "../../types/ticket";
|
|
||||||
|
|
||||||
interface TicketHeaderProps {
|
|
||||||
ticket: TicketData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TicketHeader: React.FC<TicketHeaderProps> = ({ ticket }) => {
|
|
||||||
return (
|
|
||||||
<div className="ticket-header">
|
|
||||||
<h2>
|
|
||||||
<input className="editable title-input" value={ticket.title} disabled />
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="ticket-subheader">
|
|
||||||
<div className="ticket-id">UUID {ticket.ticket_id}</div>
|
|
||||||
|
|
||||||
<div className="header-controls">
|
|
||||||
<div className="status-priority-group">
|
|
||||||
<span className={`status-${ticket.status.replace(" ", "-")}`}>
|
|
||||||
{ticket.status}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className={`priority-indicator priority-${ticket.priority}`}>
|
|
||||||
P{ticket.priority}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="btn">Edit Ticket</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TicketHeader;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface TicketTabsProps {
|
|
||||||
active: "description" | "comments";
|
|
||||||
setActiveTab: (tab: "description" | "comments") => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TicketTabs: React.FC<TicketTabsProps> = ({ active, setActiveTab }) => {
|
|
||||||
return (
|
|
||||||
<div className="ticket-tabs">
|
|
||||||
<button
|
|
||||||
className={`tab-btn ${active === "description" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("description")}
|
|
||||||
>
|
|
||||||
Description
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={`tab-btn ${active === "comments" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("comments")}
|
|
||||||
>
|
|
||||||
Comments
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TicketTabs;
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
|
|
||||||
import TicketHeader from "./TicketHeader";
|
|
||||||
import TicketTabs from "./TicketTabs";
|
|
||||||
import TicketDescription from "./TicketDescription";
|
|
||||||
|
|
||||||
import type { CommentData } from "../../types/comments";
|
|
||||||
import type { TicketData } from "../../types/ticket";
|
|
||||||
import CommentsSection from "../Comments/CommentSection";
|
|
||||||
|
|
||||||
import mockTicket from "../../mockData/ticket.json";
|
|
||||||
import mockComment from "../../mockData/comments.json";
|
|
||||||
|
|
||||||
const TicketView: React.FC = () => {
|
|
||||||
const [ticket] = useState<TicketData>(mockTicket);
|
|
||||||
const [comments, setComments] = useState<CommentData[]>(mockComment);
|
|
||||||
const [activeTab, setActiveTab] = useState<"description" | "comments">(
|
|
||||||
"description"
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`ticket-container priority-${ticket.priority}`}>
|
|
||||||
<TicketHeader ticket={ticket} />
|
|
||||||
|
|
||||||
<TicketTabs active={activeTab} setActiveTab={setActiveTab} />
|
|
||||||
|
|
||||||
{activeTab === "description" && (
|
|
||||||
<TicketDescription description={ticket.description} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "comments" && (
|
|
||||||
<CommentsSection comments={comments} setComments={setComments} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="ticket-footer">
|
|
||||||
<button
|
|
||||||
className="btn back-btn"
|
|
||||||
onClick={() => (window.location.href = "/")}
|
|
||||||
>
|
|
||||||
Back to Dashboard
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TicketView;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,68 +0,0 @@
|
|||||||
:root {
|
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.2em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { StrictMode } from 'react'
|
|
||||||
import { createRoot } from 'react-dom/client'
|
|
||||||
import './index.css'
|
|
||||||
import App from './App.tsx'
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
)
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"user_name": "Alice",
|
|
||||||
"comment_text": "Investigating this now.\n\nWill update once I confirm whether it's a firewall or routing issue.",
|
|
||||||
"created_at": "2025-01-28T10:15:00Z",
|
|
||||||
"markdown_enabled": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"user_name": "Bob",
|
|
||||||
"comment_text": "### Update\nVPN gateway logs show connection attempts reaching the server.\n\nLikely a client configuration issue.\n\nWill push config refresh.",
|
|
||||||
"created_at": "2025-01-28T11:05:12Z",
|
|
||||||
"markdown_enabled": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "",
|
|
||||||
"status": "Open",
|
|
||||||
"priority": "4",
|
|
||||||
"category": "General",
|
|
||||||
"type": "Issue",
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"ticket_id": "TCK-123456",
|
|
||||||
"title": "User cannot connect to VPN",
|
|
||||||
"status": "Open",
|
|
||||||
"priority": 2,
|
|
||||||
"category": "Network",
|
|
||||||
"type": "Issue",
|
|
||||||
"description": "User reports intermittent connection failures when attempting to establish a VPN session.\n\nSteps to reproduce:\n1. Launch VPN client\n2. Enter credentials\n3. Attempt to connect\n\nObserved: Timeout error after ~10 seconds.\nExpected: Successful connection."
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"ticket_id": "TCK-123456",
|
|
||||||
"title": "User cannot connect to VPN",
|
|
||||||
"priority": 2,
|
|
||||||
"category": "Network",
|
|
||||||
"type": "Issue",
|
|
||||||
"status": "Open",
|
|
||||||
"created_at": "2025-01-28T10:00:00Z",
|
|
||||||
"updated_at": "2025-01-28T12:00:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ticket_id": "TCK-987654",
|
|
||||||
"title": "Printer not responding",
|
|
||||||
"priority": 4,
|
|
||||||
"category": "Hardware",
|
|
||||||
"type": "Maintenance",
|
|
||||||
"status": "Closed",
|
|
||||||
"created_at": "2025-01-25T09:33:00Z",
|
|
||||||
"updated_at": "2025-01-26T14:45:00Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export interface CommentData {
|
|
||||||
user_name: string;
|
|
||||||
comment_text: string;
|
|
||||||
created_at: string; // ISO date string
|
|
||||||
markdown_enabled: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
export interface CreateTicketFormData {
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
priority: string;
|
|
||||||
category: string;
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TicketData {
|
|
||||||
ticket_id: string;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
priority: number | string;
|
|
||||||
category: string;
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TicketListItem {
|
|
||||||
ticket_id: string;
|
|
||||||
title: string;
|
|
||||||
priority: number;
|
|
||||||
category: string;
|
|
||||||
type: string;
|
|
||||||
status: string;
|
|
||||||
created_at: string; // ISO string
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"target": "ES2022",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"types": ["vite/client"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
||||||
"target": "ES2023",
|
|
||||||
"lib": ["ES2023"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"types": ["node"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
react({
|
|
||||||
babel: {
|
|
||||||
plugins: [['babel-plugin-react-compiler']],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@@ -11,24 +11,110 @@
|
|||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
|
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
|
||||||
|
<script>
|
||||||
|
// CSRF Token for AJAX requests
|
||||||
|
window.CSRF_TOKEN = '<?php
|
||||||
|
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||||
|
echo CsrfMiddleware::getToken();
|
||||||
|
?>';
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="ticket-container">
|
<div class="user-header">
|
||||||
|
<div class="user-header-left">
|
||||||
|
<a href="/" class="back-link">← Dashboard</a>
|
||||||
|
</div>
|
||||||
|
<div class="user-header-right">
|
||||||
|
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||||
|
<span class="user-name">👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
||||||
|
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
||||||
|
<span class="admin-badge">Admin</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OUTER FRAME: Create Ticket Form Container -->
|
||||||
|
<div class="ascii-frame-outer">
|
||||||
|
<span class="bottom-left-corner">╚</span>
|
||||||
|
<span class="bottom-right-corner">╝</span>
|
||||||
|
|
||||||
|
<!-- SECTION 1: Form Header -->
|
||||||
|
<div class="ascii-section-header">Create New Ticket</div>
|
||||||
|
<div class="ascii-content">
|
||||||
|
<div class="ascii-frame-inner">
|
||||||
<div class="ticket-header">
|
<div class="ticket-header">
|
||||||
<h2>Create New Ticket</h2>
|
<h2>New Ticket Form</h2>
|
||||||
|
<p style="color: var(--terminal-green); font-family: var(--font-mono); font-size: 0.9rem; margin-top: 0.5rem;">
|
||||||
|
Complete the form below to create a new ticket
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (isset($error)): ?>
|
<?php if (isset($error)): ?>
|
||||||
<div class="error-message"><?php echo $error; ?></div>
|
<!-- DIVIDER -->
|
||||||
|
<div class="ascii-divider"></div>
|
||||||
|
|
||||||
|
<!-- ERROR SECTION -->
|
||||||
|
<div class="ascii-content">
|
||||||
|
<div class="ascii-frame-inner">
|
||||||
|
<div class="error-message" style="color: var(--priority-1); border: 2px solid var(--priority-1); padding: 1rem; background: rgba(231, 76, 60, 0.1);">
|
||||||
|
<strong>⚠ Error:</strong> <?php echo $error; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- DIVIDER -->
|
||||||
|
<div class="ascii-divider"></div>
|
||||||
|
|
||||||
<form method="POST" action="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="ticket-form">
|
<form method="POST" action="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="ticket-form">
|
||||||
<div class="ticket-details">
|
|
||||||
|
<!-- SECTION 2: Template Selection -->
|
||||||
|
<div class="ascii-section-header">Template Selection</div>
|
||||||
|
<div class="ascii-content">
|
||||||
|
<div class="ascii-frame-inner">
|
||||||
<div class="detail-group">
|
<div class="detail-group">
|
||||||
<label for="title">Title</label>
|
<label for="templateSelect">Use Template (Optional)</label>
|
||||||
<input type="text" id="title" name="title" class="editable" required>
|
<select id="templateSelect" class="editable" onchange="loadTemplate()">
|
||||||
|
<option value="">-- No Template --</option>
|
||||||
|
<?php if (isset($templates) && !empty($templates)): ?>
|
||||||
|
<?php foreach ($templates as $template): ?>
|
||||||
|
<option value="<?php echo $template['template_id']; ?>">
|
||||||
|
<?php echo htmlspecialchars($template['template_name']); ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</select>
|
||||||
|
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
|
||||||
|
Select a template to auto-fill form fields
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- DIVIDER -->
|
||||||
|
<div class="ascii-divider"></div>
|
||||||
|
|
||||||
|
<!-- SECTION 3: Basic Information -->
|
||||||
|
<div class="ascii-section-header">Basic Information</div>
|
||||||
|
<div class="ascii-content">
|
||||||
|
<div class="ascii-frame-inner">
|
||||||
|
<div class="detail-group">
|
||||||
|
<label for="title">Ticket Title *</label>
|
||||||
|
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DIVIDER -->
|
||||||
|
<div class="ascii-divider"></div>
|
||||||
|
|
||||||
|
<!-- SECTION 4: Ticket Metadata -->
|
||||||
|
<div class="ascii-section-header">Ticket Metadata</div>
|
||||||
|
<div class="ascii-content">
|
||||||
|
<div class="ascii-frame-inner">
|
||||||
<div class="detail-group status-priority-row">
|
<div class="detail-group status-priority-row">
|
||||||
<div class="detail-quarter">
|
<div class="detail-quarter">
|
||||||
<label for="status">Status</label>
|
<label for="status">Status</label>
|
||||||
@@ -67,18 +153,39 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DIVIDER -->
|
||||||
|
<div class="ascii-divider"></div>
|
||||||
|
|
||||||
|
<!-- SECTION 5: Detailed Description -->
|
||||||
|
<div class="ascii-section-header">Detailed Description</div>
|
||||||
|
<div class="ascii-content">
|
||||||
|
<div class="ascii-frame-inner">
|
||||||
<div class="detail-group full-width">
|
<div class="detail-group full-width">
|
||||||
<label for="description">Description</label>
|
<label for="description">Description *</label>
|
||||||
<textarea id="description" name="description" class="editable" rows="15" required></textarea>
|
<textarea id="description" name="description" class="editable" rows="15" required placeholder="Provide a detailed description of the ticket..."></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- DIVIDER -->
|
||||||
|
<div class="ascii-divider"></div>
|
||||||
|
|
||||||
|
<!-- SECTION 6: Form Actions -->
|
||||||
|
<div class="ascii-section-header">Form Actions</div>
|
||||||
|
<div class="ascii-content">
|
||||||
|
<div class="ascii-frame-inner">
|
||||||
<div class="ticket-footer">
|
<div class="ticket-footer">
|
||||||
<button type="submit" class="btn primary">Create Ticket</button>
|
<button type="submit" class="btn primary">Create Ticket</button>
|
||||||
<button type="button" onclick="window.location.href='<?php echo $GLOBALS['config']['BASE_URL']; ?>/'" class="btn back-btn">Cancel</button>
|
<button type="button" onclick="window.location.href='<?php echo $GLOBALS['config']['BASE_URL']; ?>/'" class="btn back-btn">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- END OUTER FRAME -->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user