Compare commits
12 Commits
962724d811
...
react_test
| Author | SHA1 | Date | |
|---|---|---|---|
| 65fc9fb072 | |||
| b504cdb090 | |||
| e7383f9da9 | |||
| db9692290c | |||
| 633ac1c1d4 | |||
| d2feeb3a56 | |||
| 4d4dcdf705 | |||
| e58e2d539f | |||
| 295a869f48 | |||
| 650002911e | |||
| 91e00f571c | |||
|
|
e99f6b9a46 |
727
Claude.md
727
Claude.md
@@ -1,727 +0,0 @@
|
|||||||
# 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,274 +1,38 @@
|
|||||||
# Tinker Tickets
|
# Tinker Tickets
|
||||||
|
|
||||||
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management.
|
A lightweight PHP-based ticketing system designed for tracking and managing data center infrastructure issues.
|
||||||
|
|
||||||
## ✨ Core Features
|
## Features
|
||||||
|
|
||||||
### 📊 Dashboard & Ticket Management
|
- 📊 Clean dashboard interface with sortable columns
|
||||||
- **Smart Dashboard**: Sortable columns, advanced filtering by status/priority/category/type
|
- 🎫 Customizable ticket creation and management
|
||||||
- **Full-Text Search**: Search across tickets, descriptions, and metadata
|
- 🔄 Real-time status updates and priority tracking
|
||||||
- **Ticket Assignment**: Assign tickets to specific users with "Assigned To" column
|
- 💬 Markdown-supported commenting system
|
||||||
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators
|
- 🔔 Discord integration for notifications
|
||||||
- **Custom Categories**: Hardware, Software, Network, Security, General
|
- 📱 Mobile-responsive design
|
||||||
- **Ticket Types**: Maintenance, Install, Task, Upgrade, Issue, Problem
|
|
||||||
|
|
||||||
### 🔄 Workflow Management
|
## Core Components
|
||||||
- **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
|
|
||||||
|
|
||||||
### 💬 Collaboration Features
|
- **Dashboard**: View and filter tickets by status, priority, and type
|
||||||
- **Markdown Comments**: Full Markdown support with live preview
|
- **Ticket Management**: Create, edit, and update ticket details
|
||||||
- **User Tracking**: Tracks who created, updated, and assigned tickets
|
- **Priority Levels**: P1 (Critical) to P4 (Low) impact tracking
|
||||||
- **Activity Timeline**: Shows all ticket events (creates, updates, assignments, comments)
|
- **Categories**: Hardware, Software, Network, Security tracking
|
||||||
- **Real-time Updates**: AJAX-powered updates without page refreshes
|
- **Comment System**: Markdown support for detailed documentation
|
||||||
|
|
||||||
### 🎫 Ticket Templates
|
## Technical Details
|
||||||
- **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
|
|
||||||
|
|
||||||
### 👥 User Management & Authentication
|
- Backend: PHP with MySQL database
|
||||||
- **SSO Integration**: Authelia authentication with LLDAP backend
|
- Frontend: HTML5, CSS3, JavaScript
|
||||||
- **Role-Based Access**: Admin and standard user roles
|
- Authentication: Environment-based configuration
|
||||||
- **User Display Names**: Support for display names and usernames
|
- API: RESTful endpoints for ticket operations
|
||||||
- **Session Management**: Secure PHP session handling
|
|
||||||
|
|
||||||
### ⚡ Bulk Actions (Admin Only)
|
## Configuration
|
||||||
- **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
|
|
||||||
|
|
||||||
### 🔔 Notifications
|
1. Create `.env` file with database credentials:
|
||||||
- **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=10.10.10.50
|
DB_HOST=localhost
|
||||||
DB_USER=tinkertickets
|
DB_USER=username
|
||||||
DB_PASS=your_password
|
DB_PASS=password
|
||||||
DB_NAME=ticketing_system
|
DB_NAME=database
|
||||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
DISCORD_WEBHOOK_URL=your_webhook_url
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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,7 +10,6 @@ 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");
|
||||||
@@ -22,16 +21,6 @@ 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");
|
|
||||||
}
|
|
||||||
|
|
||||||
$currentUser = $_SESSION['user'];
|
|
||||||
$userId = $currentUser['user_id'];
|
|
||||||
|
|
||||||
// Create database connection
|
// Create database connection
|
||||||
$conn = new mysqli(
|
$conn = new mysqli(
|
||||||
@@ -54,22 +43,11 @@ try {
|
|||||||
|
|
||||||
$ticketId = $data['ticket_id'];
|
$ticketId = $data['ticket_id'];
|
||||||
|
|
||||||
// Initialize models
|
// Initialize CommentModel directly
|
||||||
$commentModel = new CommentModel($conn);
|
$commentModel = new CommentModel($conn);
|
||||||
$auditLog = new AuditLogModel($conn);
|
|
||||||
|
|
||||||
// Add comment with user tracking
|
// Add comment
|
||||||
$result = $commentModel->addComment($ticketId, $data, $userId);
|
$result = $commentModel->addComment($ticketId, $data);
|
||||||
|
|
||||||
// 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();
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$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]);
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
<?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();
|
|
||||||
?>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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"
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?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']);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?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]);
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$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,14 +28,7 @@ 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);
|
||||||
$key = trim($key);
|
$envVars[trim($key)] = trim($value);
|
||||||
$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");
|
||||||
@@ -44,44 +37,22 @@ 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");
|
|
||||||
}
|
|
||||||
$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 = [], $userId = null, $isAdmin = false) {
|
public function __construct($conn, $envVars = []) {
|
||||||
$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) {
|
||||||
@@ -127,37 +98,25 @@ try {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate status transition using workflow model
|
// Validate status
|
||||||
if ($currentTicket['status'] !== $updateData['status']) {
|
$validStatuses = ['Open', 'Closed', 'In Progress', 'Pending'];
|
||||||
$allowed = $this->workflowModel->isTransitionAllowed(
|
if (!in_array($updateData['status'], $validStatuses)) {
|
||||||
$currentTicket['status'],
|
return [
|
||||||
$updateData['status'],
|
'success' => false,
|
||||||
$this->isAdmin
|
'error' => 'Invalid status value'
|
||||||
);
|
];
|
||||||
|
|
||||||
if (!$allowed) {
|
|
||||||
return [
|
|
||||||
'success' => false,
|
|
||||||
'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 with user tracking
|
// Update ticket
|
||||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId);
|
$result = $this->ticketModel->updateTicket($updateData);
|
||||||
|
|
||||||
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
|
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
// Log ticket update to audit log
|
// Send Discord webhook notification
|
||||||
if ($this->userId) {
|
$this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
|
||||||
$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,
|
||||||
@@ -300,7 +259,7 @@ try {
|
|||||||
|
|
||||||
// Initialize controller
|
// Initialize controller
|
||||||
debug_log("Initializing controller");
|
debug_log("Initializing controller");
|
||||||
$controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin);
|
$controller = new ApiTicketController($conn, $envVars);
|
||||||
debug_log("Controller initialized");
|
debug_log("Controller initialized");
|
||||||
|
|
||||||
// Update ticket
|
// Update ticket
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$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
@@ -1,361 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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() {
|
|
||||||
const filterName = prompt('Enter a name for this filter:');
|
|
||||||
if (!filterName || filterName.trim() === '') return;
|
|
||||||
|
|
||||||
const filterCriteria = getCurrentFilterCriteria();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/saved_filters.php', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
filter_name: filterName.trim(),
|
|
||||||
filter_criteria: filterCriteria
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.success(`Filter "${filterName}" saved successfully!`);
|
|
||||||
}
|
|
||||||
loadSavedFilters();
|
|
||||||
} else {
|
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving filter:', error);
|
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.error('Error saving filter');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete the filter "${filterName}"?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/saved_filters.php', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ filter_id: filterId })
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.success('Filter deleted successfully');
|
|
||||||
}
|
|
||||||
loadSavedFilters();
|
|
||||||
resetAdvancedSearch();
|
|
||||||
} else {
|
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.error('Failed to delete filter');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting filter:', error);
|
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.error('Error deleting filter');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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' },
|
|
||||||
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,12 +18,7 @@ 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
|
data[field.dataset.field] = field.value;
|
||||||
if (field.hasAttribute('contenteditable')) {
|
|
||||||
data[field.dataset.field] = field.textContent.trim();
|
|
||||||
} else {
|
|
||||||
data[field.dataset.field] = field.value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,7 +51,7 @@ function saveTicket() {
|
|||||||
statusDisplay.className = `status-${data.status}`;
|
statusDisplay.className = `status-${data.status}`;
|
||||||
statusDisplay.textContent = data.status;
|
statusDisplay.textContent = data.status;
|
||||||
}
|
}
|
||||||
toast.success('Ticket updated successfully');
|
console.log('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');
|
||||||
}
|
}
|
||||||
@@ -68,47 +63,23 @@ function saveTicket() {
|
|||||||
|
|
||||||
function toggleEditMode() {
|
function toggleEditMode() {
|
||||||
const editButton = document.getElementById('editButton');
|
const editButton = document.getElementById('editButton');
|
||||||
const titleField = document.querySelector('.title-input');
|
const editables = document.querySelectorAll('.title-input, textarea[data-field="description"]');
|
||||||
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 => {
|
||||||
// Enable title (contenteditable div)
|
|
||||||
if (titleField) {
|
|
||||||
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;
|
field.disabled = false;
|
||||||
|
if (field.classList.contains('title-input')) {
|
||||||
|
field.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} 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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -243,260 +214,37 @@ 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(descriptionTextarea);
|
autoResizeTextarea();
|
||||||
|
|
||||||
// Resize on input when in edit mode
|
// Resize on input when in edit mode
|
||||||
descriptionTextarea.addEventListener('input', function() {
|
descriptionTextarea.addEventListener('input', autoResizeTextarea);
|
||||||
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' },
|
|
||||||
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' },
|
|
||||||
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) {
|
|
||||||
const proceed = confirm(`This status change requires a comment. Please add a comment explaining the reason for this transition.\n\nProceed with status change to "${newStatus}"?`);
|
|
||||||
if (!proceed) {
|
|
||||||
// Reset to current status
|
|
||||||
statusSelect.selectedIndex = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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'
|
|
||||||
},
|
|
||||||
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 all tabs
|
// Hide both 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 => {
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* Terminal-style toast notification system
|
|
||||||
*/
|
|
||||||
|
|
||||||
function showToast(message, type = 'info', duration = 3000) {
|
|
||||||
// Remove any existing toasts
|
|
||||||
const existingToast = document.querySelector('.terminal-toast');
|
|
||||||
if (existingToast) {
|
|
||||||
existingToast.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create toast element
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `terminal-toast toast-${type}`;
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add to document
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
// Trigger animation
|
|
||||||
setTimeout(() => toast.classList.add('show'), 10);
|
|
||||||
|
|
||||||
// Auto-remove after duration
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.classList.remove('show');
|
|
||||||
setTimeout(() => toast.remove(), 300);
|
|
||||||
}, duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,19 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
$envFile = __DIR__ . '/../.env';
|
$envFile = __DIR__ . '/../.env';
|
||||||
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
$envVars = parse_ini_file($envFile);
|
||||||
|
|
||||||
// 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,67 +1,37 @@
|
|||||||
<?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;
|
$search = isset($_GET['search']) ? trim($_GET['search']) : null; // ADD THIS LINE
|
||||||
|
|
||||||
// Handle status filtering with user preferences
|
// Handle status filtering
|
||||||
$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'])) {
|
||||||
// Get default status filters from user preferences
|
// Default: show Open and In Progress (exclude Closed)
|
||||||
if ($userId) {
|
$status = 'Open,In Progress';
|
||||||
$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)
|
||||||
|
|
||||||
// Build advanced search filters array
|
// Get tickets with pagination, sorting, and search
|
||||||
$filters = [];
|
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search);
|
||||||
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,27 +2,15 @@
|
|||||||
// 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';
|
||||||
@@ -32,24 +20,13 @@ 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);
|
||||||
$key = trim($key);
|
$this->envVars[trim($key)] = trim($value);
|
||||||
$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);
|
||||||
|
|
||||||
@@ -62,24 +39,11 @@ 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 = [
|
||||||
@@ -93,20 +57,14 @@ 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 with user tracking
|
// Create ticket
|
||||||
$result = $this->ticketModel->createTicket($ticketData, $userId);
|
$result = $this->ticketModel->createTicket($ticketData);
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -115,24 +73,16 @@ 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
|
||||||
@@ -152,13 +102,8 @@ class TicketController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update ticket with user tracking
|
// Update ticket
|
||||||
$result = $this->ticketModel->updateTicket($data, $userId);
|
$result = $this->ticketModel->updateTicket($data);
|
||||||
|
|
||||||
// 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');
|
||||||
@@ -189,7 +134,7 @@ class TicketController {
|
|||||||
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
|
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
|
||||||
|
|
||||||
// Create ticket URL
|
// Create ticket URL
|
||||||
$ticketUrl = "http://t.lotusguild.org/ticket/$ticketId";
|
$ticketUrl = "http://tinkertickets.local/ticket/$ticketId";
|
||||||
|
|
||||||
// Map priorities to Discord colors
|
// Map priorities to Discord colors
|
||||||
$priorityColors = [
|
$priorityColors = [
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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)) {
|
||||||
@@ -15,7 +16,7 @@ if (!file_exists($envFile)) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
$envVars = parse_ini_file($envFile);
|
||||||
if (!$envVars) {
|
if (!$envVars) {
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
@@ -24,16 +25,6 @@ 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'],
|
||||||
@@ -50,22 +41,6 @@ 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,
|
||||||
@@ -84,32 +59,22 @@ $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, /dev/nvmeXnY patterns)
|
// Extract device name if present (matches /dev/sdX pattern)
|
||||||
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches);
|
preg_match('/\/dev\/sd[a-z]/', $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] ?? '';
|
||||||
|
|
||||||
// Detect issue category (not specific attribute values)
|
// Extract SMART attribute types without their values
|
||||||
$issueCategory = '';
|
preg_match_all('/Warning ([^:]+)/', $data['title'], $smartMatches);
|
||||||
if (stripos($data['title'], 'SMART issues') !== false) {
|
$smartAttributes = $smartMatches[1] ?? [];
|
||||||
$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,
|
||||||
'issue_category' => $issueCategory, // Generic category, not specific errors
|
'smart_attributes' => $smartAttributes,
|
||||||
'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'])
|
||||||
@@ -122,6 +87,7 @@ 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));
|
||||||
@@ -156,9 +122,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 with created_by field
|
// Prepare insert query
|
||||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
// First, store all values in variables
|
// First, store all values in variables
|
||||||
@@ -171,7 +137,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(
|
||||||
"ssssssssi",
|
"ssssssss",
|
||||||
$ticket_id,
|
$ticket_id,
|
||||||
$title,
|
$title,
|
||||||
$description,
|
$description,
|
||||||
@@ -179,20 +145,10 @@ $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,
|
||||||
@@ -224,7 +180,7 @@ $discord_data = [
|
|||||||
"embeds" => [[
|
"embeds" => [[
|
||||||
"title" => "New Ticket Created: #" . $ticket_id,
|
"title" => "New Ticket Created: #" . $ticket_id,
|
||||||
"description" => $title,
|
"description" => $title,
|
||||||
"url" => "http://t.lotusguild.org/ticket/" . $ticket_id,
|
"url" => "http://tinkertickets.local/ticket/" . $ticket_id,
|
||||||
"color" => $priorityColors[$priority],
|
"color" => $priorityColors[$priority],
|
||||||
"fields" => [
|
"fields" => [
|
||||||
["name" => "Priority", "value" => $priority, "inline" => true],
|
["name" => "Priority", "value" => $priority, "inline" => true],
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
<?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,8 +1,6 @@
|
|||||||
<?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'];
|
||||||
@@ -22,16 +20,6 @@ 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
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
<?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
|
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store user in session
|
|
||||||
$_SESSION['user'] = $user;
|
|
||||||
$_SESSION['last_activity'] = time();
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- 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
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- 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)
|
|
||||||
);
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- Remove all ticket view tracking records from audit_log
|
|
||||||
DELETE FROM audit_log WHERE action_type = 'view';
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# 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';
|
|
||||||
```
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<?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();
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,443 +0,0 @@
|
|||||||
<?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)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
<?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);
|
|
||||||
|
|
||||||
foreach ($ticketIds as $ticketId) {
|
|
||||||
$ticketId = trim($ticketId);
|
|
||||||
$success = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch ($operation['operation_type']) {
|
|
||||||
case 'bulk_close':
|
|
||||||
// Get current ticket to preserve other fields
|
|
||||||
$currentTicket = $ticketModel->getTicketById($ticketId);
|
|
||||||
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 = $ticketModel->getTicketById($ticketId);
|
|
||||||
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 = $ticketModel->getTicketById($ticketId);
|
|
||||||
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,11 +7,7 @@ class CommentModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getCommentsByTicketId($ticketId) {
|
public function getCommentsByTicketId($ticketId) {
|
||||||
$sql = "SELECT tc.*, u.display_name, u.username
|
$sql = "SELECT * FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at DESC";
|
||||||
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();
|
||||||
@@ -19,25 +15,19 @@ 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, $userId = null) {
|
public function addComment($ticketId, $commentData) {
|
||||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled)
|
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||||
VALUES (?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|
||||||
// Set default username (kept for backward compatibility)
|
// Set default username
|
||||||
$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;
|
||||||
|
|
||||||
@@ -45,20 +35,16 @@ class CommentModel {
|
|||||||
$commentText = $commentData['comment_text'];
|
$commentText = $commentData['comment_text'];
|
||||||
|
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"sissi",
|
"sssi",
|
||||||
$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,
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
<?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,18 +7,7 @@ class TicketModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getTicketById($id) {
|
public function getTicketById($id) {
|
||||||
$sql = "SELECT t.*,
|
$sql = "SELECT * FROM tickets WHERE ticket_id = ?";
|
||||||
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();
|
||||||
@@ -46,7 +35,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, $filters = []) {
|
public function getAllTickets($page = 1, $limit = 15, $status = 'Open', $sortColumn = 'ticket_id', $sortDirection = 'desc', $category = null, $type = null, $search = null) {
|
||||||
// Calculate offset
|
// Calculate offset
|
||||||
$offset = ($page - 1) * $limit;
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
@@ -90,61 +79,6 @@ 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);
|
||||||
@@ -171,18 +105,8 @@ class TicketModel {
|
|||||||
$totalResult = $countStmt->get_result();
|
$totalResult = $countStmt->get_result();
|
||||||
$totalTickets = $totalResult->fetch_assoc()['total'];
|
$totalTickets = $totalResult->fetch_assoc()['total'];
|
||||||
|
|
||||||
// Get tickets with pagination and creator info
|
// Get tickets with pagination
|
||||||
$sql = "SELECT t.*,
|
$sql = "SELECT * FROM tickets $whereClause ORDER BY $sortColumn $sortDirection LIMIT ? OFFSET ?";
|
||||||
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 $sortColumn $sortDirection
|
|
||||||
LIMIT ? OFFSET ?";
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|
||||||
// Add limit and offset parameters
|
// Add limit and offset parameters
|
||||||
@@ -210,7 +134,7 @@ class TicketModel {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateTicket($ticketData, $updatedBy = null) {
|
public function updateTicket($ticketData) {
|
||||||
// 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;
|
||||||
@@ -230,7 +154,6 @@ class TicketModel {
|
|||||||
description = ?,
|
description = ?,
|
||||||
category = ?,
|
category = ?,
|
||||||
type = ?,
|
type = ?,
|
||||||
updated_by = ?,
|
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE ticket_id = ?";
|
WHERE ticket_id = ?";
|
||||||
|
|
||||||
@@ -245,14 +168,13 @@ class TicketModel {
|
|||||||
|
|
||||||
$debug("Binding parameters");
|
$debug("Binding parameters");
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"sissssii",
|
"sissssi",
|
||||||
$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']
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -273,12 +195,12 @@ class TicketModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createTicket($ticketData, $createdBy = null) {
|
public function createTicket($ticketData) {
|
||||||
// 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, created_by)
|
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|
||||||
@@ -289,15 +211,14 @@ class TicketModel {
|
|||||||
$type = $ticketData['type'] ?? 'Issue';
|
$type = $ticketData['type'] ?? 'Issue';
|
||||||
|
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"sssssssi",
|
"sssssss",
|
||||||
$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()) {
|
||||||
@@ -344,37 +265,4 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* UserModel - Handles user authentication and management
|
|
||||||
*/
|
|
||||||
class UserModel {
|
|
||||||
private $conn;
|
|
||||||
private static $userCache = [];
|
|
||||||
|
|
||||||
public function __construct($conn) {
|
|
||||||
$this->conn = $conn;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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";
|
|
||||||
if (isset(self::$userCache[$cacheKey])) {
|
|
||||||
return self::$userCache[$cacheKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
self::$userCache[$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
|
|
||||||
if (isset(self::$userCache['system'])) {
|
|
||||||
return self::$userCache['system'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$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::$userCache['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";
|
|
||||||
if (isset(self::$userCache[$cacheKey])) {
|
|
||||||
return self::$userCache[$cacheKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
$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::$userCache[$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";
|
|
||||||
if (isset(self::$userCache[$cacheKey])) {
|
|
||||||
return self::$userCache[$cacheKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
$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::$userCache[$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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<?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
Normal file
24
tinker_tickets_react/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 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?
|
||||||
75
tinker_tickets_react/README.md
Normal file
75
tinker_tickets_react/README.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# 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...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
tinker_tickets_react/eslint.config.js
Normal file
23
tinker_tickets_react/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
tinker_tickets_react/index.html
Normal file
13
tinker_tickets_react/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!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
Normal file
3302
tinker_tickets_react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
tinker_tickets_react/package.json
Normal file
33
tinker_tickets_react/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
tinker_tickets_react/src/App.css
Normal file
42
tinker_tickets_react/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
34
tinker_tickets_react/src/App.tsx
Normal file
34
tinker_tickets_react/src/App.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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;
|
||||||
72
tinker_tickets_react/src/Components/Comments/CommentForm.tsx
Normal file
72
tinker_tickets_react/src/Components/Comments/CommentForm.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
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;
|
||||||
36
tinker_tickets_react/src/Components/Comments/CommentItem.tsx
Normal file
36
tinker_tickets_react/src/Components/Comments/CommentItem.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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;
|
||||||
19
tinker_tickets_react/src/Components/Comments/CommentList.tsx
Normal file
19
tinker_tickets_react/src/Components/Comments/CommentList.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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
tinker_tickets_react/src/assets/react.svg
Normal file
1
tinker_tickets_react/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
68
tinker_tickets_react/src/index.css
Normal file
68
tinker_tickets_react/src/index.css
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
tinker_tickets_react/src/main.tsx
Normal file
10
tinker_tickets_react/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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>,
|
||||||
|
)
|
||||||
14
tinker_tickets_react/src/mockData/comments.json
Normal file
14
tinker_tickets_react/src/mockData/comments.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
8
tinker_tickets_react/src/mockData/empty_ticket.json
Normal file
8
tinker_tickets_react/src/mockData/empty_ticket.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"status": "Open",
|
||||||
|
"priority": "4",
|
||||||
|
"category": "General",
|
||||||
|
"type": "Issue",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
9
tinker_tickets_react/src/mockData/ticket.json
Normal file
9
tinker_tickets_react/src/mockData/ticket.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
}
|
||||||
22
tinker_tickets_react/src/mockData/tickets.json
Normal file
22
tinker_tickets_react/src/mockData/tickets.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
6
tinker_tickets_react/src/types/comments.ts
Normal file
6
tinker_tickets_react/src/types/comments.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface CommentData {
|
||||||
|
user_name: string;
|
||||||
|
comment_text: string;
|
||||||
|
created_at: string; // ISO date string
|
||||||
|
markdown_enabled: boolean;
|
||||||
|
}
|
||||||
34
tinker_tickets_react/src/types/ticket.ts
Normal file
34
tinker_tickets_react/src/types/ticket.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
28
tinker_tickets_react/tsconfig.app.json
Normal file
28
tinker_tickets_react/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
7
tinker_tickets_react/tsconfig.json
Normal file
7
tinker_tickets_react/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tinker_tickets_react/tsconfig.node.json
Normal file
26
tinker_tickets_react/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
13
tinker_tickets_react/vite.config.ts
Normal file
13
tinker_tickets_react/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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']],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -13,172 +13,72 @@
|
|||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
|
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="user-header">
|
<div class="ticket-container">
|
||||||
<div class="user-header-left">
|
<div class="ticket-header">
|
||||||
<a href="/" class="back-link">← Dashboard</a>
|
<h2>Create New Ticket</h2>
|
||||||
</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">
|
|
||||||
<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)): ?>
|
||||||
<!-- DIVIDER -->
|
<div class="error-message"><?php echo $error; ?></div>
|
||||||
<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">
|
||||||
|
<div class="detail-group">
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input type="text" id="title" name="title" class="editable" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- SECTION 2: Template Selection -->
|
<div class="detail-group status-priority-row">
|
||||||
<div class="ascii-section-header">Template Selection</div>
|
<div class="detail-quarter">
|
||||||
<div class="ascii-content">
|
<label for="status">Status</label>
|
||||||
<div class="ascii-frame-inner">
|
<select id="status" name="status" class="editable">
|
||||||
<div class="detail-group">
|
<option value="Open" selected>Open</option>
|
||||||
<label for="templateSelect">Use Template (Optional)</label>
|
<option value="Closed">Closed</option>
|
||||||
<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>
|
</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 class="detail-quarter">
|
||||||
|
<label for="priority">Priority</label>
|
||||||
|
<select id="priority" name="priority" class="editable">
|
||||||
|
<option value="1">P1 - Critical Impact</option>
|
||||||
|
<option value="2">P2 - High Impact</option>
|
||||||
|
<option value="3">P3 - Medium Impact</option>
|
||||||
|
<option value="4" selected>P4 - Low Impact</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="detail-quarter">
|
||||||
|
<label for="category">Category</label>
|
||||||
|
<select id="category" name="category" class="editable">
|
||||||
|
<option value="Hardware">Hardware</option>
|
||||||
|
<option value="Software">Software</option>
|
||||||
|
<option value="Network">Network</option>
|
||||||
|
<option value="Security">Security</option>
|
||||||
|
<option value="General" selected>General</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="detail-quarter">
|
||||||
|
<label for="type">Type</label>
|
||||||
|
<select id="type" name="type" class="editable">
|
||||||
|
<option value="Maintenance">Maintenance</option>
|
||||||
|
<option value="Install">Install</option>
|
||||||
|
<option value="Task">Task</option>
|
||||||
|
<option value="Upgrade">Upgrade</option>
|
||||||
|
<option value="Issue" selected>Issue</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-group full-width">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea id="description" name="description" class="editable" rows="15" required></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DIVIDER -->
|
<div class="ticket-footer">
|
||||||
<div class="ascii-divider"></div>
|
<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>
|
||||||
<!-- 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>
|
</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-quarter">
|
|
||||||
<label for="status">Status</label>
|
|
||||||
<select id="status" name="status" class="editable">
|
|
||||||
<option value="Open" selected>Open</option>
|
|
||||||
<option value="Closed">Closed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="detail-quarter">
|
|
||||||
<label for="priority">Priority</label>
|
|
||||||
<select id="priority" name="priority" class="editable">
|
|
||||||
<option value="1">P1 - Critical Impact</option>
|
|
||||||
<option value="2">P2 - High Impact</option>
|
|
||||||
<option value="3">P3 - Medium Impact</option>
|
|
||||||
<option value="4" selected>P4 - Low Impact</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="detail-quarter">
|
|
||||||
<label for="category">Category</label>
|
|
||||||
<select id="category" name="category" class="editable">
|
|
||||||
<option value="Hardware">Hardware</option>
|
|
||||||
<option value="Software">Software</option>
|
|
||||||
<option value="Network">Network</option>
|
|
||||||
<option value="Security">Security</option>
|
|
||||||
<option value="General" selected>General</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="detail-quarter">
|
|
||||||
<label for="type">Type</label>
|
|
||||||
<select id="type" name="type" class="editable">
|
|
||||||
<option value="Maintenance">Maintenance</option>
|
|
||||||
<option value="Install">Install</option>
|
|
||||||
<option value="Task">Task</option>
|
|
||||||
<option value="Upgrade">Upgrade</option>
|
|
||||||
<option value="Issue" selected>Issue</option>
|
|
||||||
</select>
|
|
||||||
</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">
|
|
||||||
<label for="description">Description *</label>
|
|
||||||
<textarea id="description" name="description" class="editable" rows="15" required placeholder="Provide a detailed description of the ticket..."></textarea>
|
|
||||||
</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">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<!-- END OUTER FRAME -->
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -10,208 +10,56 @@
|
|||||||
<title>Ticket Dashboard</title>
|
<title>Ticket Dashboard</title>
|
||||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||||
<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">
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script>
|
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js"></script>
|
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
|
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body data-categories='<?php echo json_encode($categories); ?>' data-types='<?php echo json_encode($types); ?>'>
|
<body data-categories='<?php echo json_encode($categories); ?>' data-types='<?php echo json_encode($types); ?>'>
|
||||||
|
<div class="dashboard-header">
|
||||||
<!-- Terminal Boot Sequence -->
|
<h1>Tinker Tickets</h1>
|
||||||
<div id="boot-sequence" class="boot-overlay">
|
<button onclick="window.location.href='<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create'" class="btn create-ticket">New Ticket</button> </div>
|
||||||
<pre id="boot-text"></pre>
|
<div class="search-container">
|
||||||
</div>
|
<form method="GET" action="" class="search-form">
|
||||||
<script>
|
<!-- Preserve existing parameters -->
|
||||||
function showBootSequence() {
|
<?php if (isset($_GET['status'])): ?>
|
||||||
const bootText = document.getElementById('boot-text');
|
<input type="hidden" name="status" value="<?php echo htmlspecialchars($_GET['status']); ?>">
|
||||||
const bootOverlay = document.getElementById('boot-sequence');
|
|
||||||
const messages = [
|
|
||||||
'╔═══════════════════════════════════════╗',
|
|
||||||
'║ TINKER TICKETS TERMINAL v1.0 ║',
|
|
||||||
'║ BOOTING SYSTEM... ║',
|
|
||||||
'╚═══════════════════════════════════════╝',
|
|
||||||
'',
|
|
||||||
'[ OK ] Loading kernel modules...',
|
|
||||||
'[ OK ] Initializing ticket database...',
|
|
||||||
'[ OK ] Mounting user session...',
|
|
||||||
'[ OK ] Starting dashboard services...',
|
|
||||||
'[ OK ] Rendering ASCII frames...',
|
|
||||||
'',
|
|
||||||
'> SYSTEM READY ✓',
|
|
||||||
''
|
|
||||||
];
|
|
||||||
|
|
||||||
let i = 0;
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (i < messages.length) {
|
|
||||||
bootText.textContent += messages[i] + '\n';
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
setTimeout(() => {
|
|
||||||
bootOverlay.style.opacity = '0';
|
|
||||||
setTimeout(() => bootOverlay.remove(), 500);
|
|
||||||
}, 500);
|
|
||||||
clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run on first visit only (per session)
|
|
||||||
if (!sessionStorage.getItem('booted')) {
|
|
||||||
showBootSequence();
|
|
||||||
sessionStorage.setItem('booted', 'true');
|
|
||||||
} else {
|
|
||||||
document.getElementById('boot-sequence').remove();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<div class="user-header">
|
|
||||||
<div class="user-header-left">
|
|
||||||
<span class="app-title">🎫 Tinker Tickets</span>
|
|
||||||
</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; ?>
|
|
||||||
<button class="settings-icon" title="Settings (Alt+S)" onclick="openSettingsModal()">⚙</button>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
<?php if (isset($_GET['show_all'])): ?>
|
||||||
|
<input type="hidden" name="show_all" value="<?php echo htmlspecialchars($_GET['show_all']); ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (isset($_GET['category'])): ?>
|
||||||
|
<input type="hidden" name="category" value="<?php echo htmlspecialchars($_GET['category']); ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (isset($_GET['type'])): ?>
|
||||||
|
<input type="hidden" name="type" value="<?php echo htmlspecialchars($_GET['type']); ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (isset($_GET['sort'])): ?>
|
||||||
|
<input type="hidden" name="sort" value="<?php echo htmlspecialchars($_GET['sort']); ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (isset($_GET['dir'])): ?>
|
||||||
|
<input type="hidden" name="dir" value="<?php echo htmlspecialchars($_GET['dir']); ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<input type="text"
|
||||||
|
name="search"
|
||||||
|
placeholder="Search tickets..."
|
||||||
|
class="search-box"
|
||||||
|
value="<?php echo isset($_GET['search']) ? htmlspecialchars($_GET['search']) : ''; ?>">
|
||||||
|
<button type="submit" class="search-btn">Search</button>
|
||||||
|
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
|
||||||
|
<a href="?" class="clear-search-btn">Clear</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
|
||||||
<!-- Collapsible ASCII Banner -->
|
<div class="search-results-info">
|
||||||
<div class="ascii-banner-wrapper collapsed">
|
Showing results for: "<strong><?php echo htmlspecialchars($_GET['search']); ?></strong>"
|
||||||
<button class="banner-toggle" onclick="toggleBanner()">
|
(<?php echo $totalTickets; ?> ticket<?php echo $totalTickets != 1 ? 's' : ''; ?> found)
|
||||||
<span class="toggle-icon">▼</span> ASCII Banner
|
|
||||||
</button>
|
|
||||||
<div id="ascii-banner-container" class="banner-content"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<?php endif; ?>
|
||||||
function toggleBanner() {
|
<div class="table-controls">
|
||||||
const wrapper = document.querySelector('.ascii-banner-wrapper');
|
<div class="ticket-count">
|
||||||
const icon = document.querySelector('.toggle-icon');
|
Total Tickets: <?php echo $totalTickets; ?>
|
||||||
wrapper.classList.toggle('collapsed');
|
|
||||||
icon.textContent = wrapper.classList.contains('collapsed') ? '▼' : '▲';
|
|
||||||
|
|
||||||
// Render banner on first expand (no animation for instant display)
|
|
||||||
if (!wrapper.classList.contains('collapsed') && !wrapper.dataset.rendered) {
|
|
||||||
renderResponsiveBanner('#ascii-banner-container', 0); // Speed 0 = no animation
|
|
||||||
wrapper.dataset.rendered = 'true';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Dashboard Layout with Sidebar -->
|
|
||||||
<div class="dashboard-layout">
|
|
||||||
<!-- Left Sidebar with Filters -->
|
|
||||||
<aside class="dashboard-sidebar">
|
|
||||||
<div class="ascii-frame-inner">
|
|
||||||
<div class="ascii-subsection-header">Filters</div>
|
|
||||||
|
|
||||||
<!-- Status Filter -->
|
|
||||||
<div class="filter-group">
|
|
||||||
<h4>Status</h4>
|
|
||||||
<?php
|
|
||||||
$currentStatus = isset($_GET['status']) ? explode(',', $_GET['status']) : ['Open', 'Pending', 'In Progress'];
|
|
||||||
$allStatuses = ['Open', 'Pending', 'In Progress', 'Closed'];
|
|
||||||
foreach ($allStatuses as $status):
|
|
||||||
?>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox"
|
|
||||||
name="status"
|
|
||||||
value="<?php echo $status; ?>"
|
|
||||||
<?php echo in_array($status, $currentStatus) ? 'checked' : ''; ?>>
|
|
||||||
<?php echo $status; ?>
|
|
||||||
</label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Category Filter -->
|
|
||||||
<div class="filter-group">
|
|
||||||
<h4>Category</h4>
|
|
||||||
<?php
|
|
||||||
$currentCategories = isset($_GET['category']) ? explode(',', $_GET['category']) : [];
|
|
||||||
foreach ($categories as $cat):
|
|
||||||
?>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox"
|
|
||||||
name="category"
|
|
||||||
value="<?php echo $cat; ?>"
|
|
||||||
<?php echo in_array($cat, $currentCategories) ? 'checked' : ''; ?>>
|
|
||||||
<?php echo htmlspecialchars($cat); ?>
|
|
||||||
</label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Type Filter -->
|
|
||||||
<div class="filter-group">
|
|
||||||
<h4>Type</h4>
|
|
||||||
<?php
|
|
||||||
$currentTypes = isset($_GET['type']) ? explode(',', $_GET['type']) : [];
|
|
||||||
foreach ($types as $type):
|
|
||||||
?>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox"
|
|
||||||
name="type"
|
|
||||||
value="<?php echo $type; ?>"
|
|
||||||
<?php echo in_array($type, $currentTypes) ? 'checked' : ''; ?>>
|
|
||||||
<?php echo htmlspecialchars($type); ?>
|
|
||||||
</label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="apply-filters-btn" class="btn">Apply Filters</button>
|
|
||||||
<button id="clear-filters-btn" class="btn btn-secondary">Clear All</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
|
||||||
<main class="dashboard-main">
|
|
||||||
|
|
||||||
<!-- CONDENSED TOOLBAR: Combined Header, Search, Actions, Pagination -->
|
|
||||||
<div class="dashboard-toolbar">
|
|
||||||
<!-- Left: Title + Search -->
|
|
||||||
<div class="toolbar-left">
|
|
||||||
<h1 class="dashboard-title">🎫 Tickets</h1>
|
|
||||||
<form method="GET" action="" class="toolbar-search">
|
|
||||||
<!-- Preserve existing parameters -->
|
|
||||||
<?php if (isset($_GET['status'])): ?>
|
|
||||||
<input type="hidden" name="status" value="<?php echo htmlspecialchars($_GET['status']); ?>">
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if (isset($_GET['category'])): ?>
|
|
||||||
<input type="hidden" name="category" value="<?php echo htmlspecialchars($_GET['category']); ?>">
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if (isset($_GET['type'])): ?>
|
|
||||||
<input type="hidden" name="type" value="<?php echo htmlspecialchars($_GET['type']); ?>">
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if (isset($_GET['sort'])): ?>
|
|
||||||
<input type="hidden" name="sort" value="<?php echo htmlspecialchars($_GET['sort']); ?>">
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if (isset($_GET['dir'])): ?>
|
|
||||||
<input type="hidden" name="dir" value="<?php echo htmlspecialchars($_GET['dir']); ?>">
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<input type="text"
|
|
||||||
name="search"
|
|
||||||
placeholder="🔍 Search tickets..."
|
|
||||||
class="search-box"
|
|
||||||
value="<?php echo isset($_GET['search']) ? htmlspecialchars($_GET['search']) : ''; ?>">
|
|
||||||
<button type="submit" class="btn search-btn">Search</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="openAdvancedSearch()" title="Advanced Search">⚙ Advanced</button>
|
|
||||||
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
|
|
||||||
<a href="?" class="clear-search-btn">✗</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="table-actions">
|
||||||
<!-- Center: Actions + Count -->
|
|
||||||
<div class="toolbar-center">
|
|
||||||
<button onclick="window.location.href='<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create'" class="btn create-ticket">+ New Ticket</button>
|
|
||||||
<span class="ticket-count">Total: <?php echo $totalTickets; ?></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: Pagination -->
|
|
||||||
<div class="toolbar-right">
|
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<?php
|
<?php
|
||||||
$currentParams = $_GET;
|
$currentParams = $_GET;
|
||||||
@@ -239,42 +87,18 @@
|
|||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
|
<table>
|
||||||
<div class="search-results-info">
|
|
||||||
Showing results for: "<strong><?php echo htmlspecialchars($_GET['search']); ?></strong>"
|
|
||||||
(<?php echo $totalTickets; ?> ticket<?php echo $totalTickets != 1 ? 's' : ''; ?> found)
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<!-- TICKET TABLE WITH INLINE BULK ACTIONS -->
|
|
||||||
<div class="ascii-frame-outer">
|
|
||||||
<span class="bottom-left-corner">╚</span>
|
|
||||||
<span class="bottom-right-corner">╝</span>
|
|
||||||
|
|
||||||
<div class="ascii-section-header">Ticket List</div>
|
|
||||||
<div class="ascii-content">
|
|
||||||
<div class="ascii-frame-inner">
|
|
||||||
<!-- Inline Bulk Actions (appears above table when items selected) -->
|
|
||||||
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
|
|
||||||
<div class="bulk-actions-inline" style="display: none;">
|
|
||||||
<span id="selected-count">0</span> tickets selected
|
|
||||||
<button onclick="showBulkStatusModal()" class="btn btn-bulk">Change Status</button>
|
|
||||||
<button onclick="showBulkAssignModal()" class="btn btn-bulk">Assign</button>
|
|
||||||
<button onclick="showBulkPriorityModal()" class="btn btn-bulk">Priority</button>
|
|
||||||
<button onclick="clearSelection()" class="btn btn-secondary">Clear</button>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<!-- Table -->
|
|
||||||
<table>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
|
|
||||||
<th style="width: 40px;"><input type="checkbox" id="selectAllCheckbox" onclick="toggleSelectAll()"></th>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php
|
<?php
|
||||||
$currentSort = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
|
$currentSort = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
|
||||||
$currentDir = isset($_GET['dir']) ? $_GET['dir'] : 'desc';
|
$currentDir = isset($_GET['dir']) ? $_GET['dir'] : 'desc';
|
||||||
@@ -286,8 +110,6 @@
|
|||||||
'category' => 'Category',
|
'category' => 'Category',
|
||||||
'type' => 'Type',
|
'type' => 'Type',
|
||||||
'status' => 'Status',
|
'status' => 'Status',
|
||||||
'created_by' => 'Created By',
|
|
||||||
'assigned_to' => 'Assigned To',
|
|
||||||
'created_at' => 'Created',
|
'created_at' => 'Created',
|
||||||
'updated_at' => 'Updated'
|
'updated_at' => 'Updated'
|
||||||
];
|
];
|
||||||
@@ -307,289 +129,22 @@
|
|||||||
<?php
|
<?php
|
||||||
if (count($tickets) > 0) {
|
if (count($tickets) > 0) {
|
||||||
foreach($tickets as $row) {
|
foreach($tickets as $row) {
|
||||||
$creator = $row['creator_display_name'] ?? $row['creator_username'] ?? 'System';
|
|
||||||
$assignedTo = $row['assigned_display_name'] ?? $row['assigned_username'] ?? 'Unassigned';
|
|
||||||
echo "<tr class='priority-{$row['priority']}'>";
|
echo "<tr class='priority-{$row['priority']}'>";
|
||||||
|
|
||||||
// Add checkbox column for admins
|
|
||||||
if ($GLOBALS['currentUser']['is_admin'] ?? false) {
|
|
||||||
echo "<td><input type='checkbox' class='ticket-checkbox' value='{$row['ticket_id']}' onchange='updateSelectionCount()'></td>";
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "<td><a href='/ticket/{$row['ticket_id']}' class='ticket-link'>{$row['ticket_id']}</a></td>";
|
echo "<td><a href='/ticket/{$row['ticket_id']}' class='ticket-link'>{$row['ticket_id']}</a></td>";
|
||||||
echo "<td><span>{$row['priority']}</span></td>";
|
echo "<td><span>{$row['priority']}</span></td>";
|
||||||
echo "<td>" . htmlspecialchars($row['title']) . "</td>";
|
echo "<td>" . htmlspecialchars($row['title']) . "</td>";
|
||||||
echo "<td>{$row['category']}</td>";
|
echo "<td>{$row['category']}</td>";
|
||||||
echo "<td>{$row['type']}</td>";
|
echo "<td>{$row['type']}</td>";
|
||||||
echo "<td><span class='status-" . str_replace(' ', '-', $row['status']) . "'>{$row['status']}</span></td>";
|
echo "<td><span class='status-" . str_replace(' ', '-', $row['status']) . "'>{$row['status']}</span></td>";
|
||||||
echo "<td>" . htmlspecialchars($creator) . "</td>";
|
|
||||||
echo "<td>" . htmlspecialchars($assignedTo) . "</td>";
|
|
||||||
echo "<td>" . date('Y-m-d H:i', strtotime($row['created_at'])) . "</td>";
|
echo "<td>" . date('Y-m-d H:i', strtotime($row['created_at'])) . "</td>";
|
||||||
echo "<td>" . date('Y-m-d H:i', strtotime($row['updated_at'])) . "</td>";
|
echo "<td>" . date('Y-m-d H:i', strtotime($row['updated_at'])) . "</td>";
|
||||||
echo "</tr>";
|
echo "</tr>";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$colspan = ($GLOBALS['currentUser']['is_admin'] ?? false) ? '11' : '10';
|
echo "<tr><td colspan='8'>No tickets found</td></tr>";
|
||||||
echo "<tr><td colspan='$colspan' style='text-align: center; padding: 3rem;'>";
|
|
||||||
echo "<pre style='color: var(--terminal-green); text-shadow: var(--glow-green); font-size: 0.8rem; line-height: 1.2;'>";
|
|
||||||
echo "╔════════════════════════════════════════╗\n";
|
|
||||||
echo "║ ║\n";
|
|
||||||
echo "║ NO TICKETS FOUND ║\n";
|
|
||||||
echo "║ ║\n";
|
|
||||||
echo "║ [ ] Empty queue - all clear! ║\n";
|
|
||||||
echo "║ ║\n";
|
|
||||||
echo "╚════════════════════════════════════════╝";
|
|
||||||
echo "</pre>";
|
|
||||||
echo "</td></tr>";
|
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- END OUTER FRAME -->
|
|
||||||
|
|
||||||
<!-- Settings Modal -->
|
|
||||||
<div class="settings-modal" id="settingsModal" style="display: none;" onclick="closeOnBackdropClick(event)">
|
|
||||||
<div class="settings-content">
|
|
||||||
<span class="bottom-left-corner">╚</span>
|
|
||||||
<span class="bottom-right-corner">╝</span>
|
|
||||||
|
|
||||||
<div class="settings-header">
|
|
||||||
<h3>⚙ System Preferences</h3>
|
|
||||||
<button class="close-settings" onclick="closeSettingsModal()">✗</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-body">
|
|
||||||
<!-- Display Preferences -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h4>╔══ Display Preferences ══╗</h4>
|
|
||||||
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="rowsPerPage">Rows per page:</label>
|
|
||||||
<select id="rowsPerPage" class="setting-select">
|
|
||||||
<option value="15">15</option>
|
|
||||||
<option value="25">25</option>
|
|
||||||
<option value="50">50</option>
|
|
||||||
<option value="100">100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="defaultFilters">Default status filters:</label>
|
|
||||||
<div class="checkbox-group">
|
|
||||||
<label><input type="checkbox" name="defaultFilters" value="Open" checked> Open</label>
|
|
||||||
<label><input type="checkbox" name="defaultFilters" value="Pending" checked> Pending</label>
|
|
||||||
<label><input type="checkbox" name="defaultFilters" value="In Progress" checked> In Progress</label>
|
|
||||||
<label><input type="checkbox" name="defaultFilters" value="Closed"> Closed</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="tableDensity">Table density:</label>
|
|
||||||
<select id="tableDensity" class="setting-select">
|
|
||||||
<option value="compact">Compact</option>
|
|
||||||
<option value="normal" selected>Normal</option>
|
|
||||||
<option value="comfortable">Comfortable</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notifications -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h4>╔══ Notifications ══╗</h4>
|
|
||||||
|
|
||||||
<div class="setting-row">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="notificationsEnabled" checked>
|
|
||||||
Enable browser notifications
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-row">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="soundEffects" checked>
|
|
||||||
Sound effects
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="toastDuration">Toast duration:</label>
|
|
||||||
<select id="toastDuration" class="setting-select">
|
|
||||||
<option value="3000" selected>3 seconds</option>
|
|
||||||
<option value="5000">5 seconds</option>
|
|
||||||
<option value="10000">10 seconds</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Keyboard Shortcuts -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h4>╔══ Keyboard Shortcuts ══╗</h4>
|
|
||||||
<div class="shortcuts-list">
|
|
||||||
<div class="shortcut-item">
|
|
||||||
<kbd>Ctrl/Cmd + K</kbd> <span>Focus search</span>
|
|
||||||
</div>
|
|
||||||
<div class="shortcut-item">
|
|
||||||
<kbd>Alt + S</kbd> <span>Open settings</span>
|
|
||||||
</div>
|
|
||||||
<div class="shortcut-item">
|
|
||||||
<kbd>ESC</kbd> <span>Close modal</span>
|
|
||||||
</div>
|
|
||||||
<div class="shortcut-item">
|
|
||||||
<kbd>?</kbd> <span>Show shortcuts</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Info (Read-only) -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h4>╔══ User Information ══╗</h4>
|
|
||||||
<div class="user-info-grid">
|
|
||||||
<div><strong>Display Name:</strong></div>
|
|
||||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? 'N/A'); ?></div>
|
|
||||||
|
|
||||||
<div><strong>Username:</strong></div>
|
|
||||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['username']); ?></div>
|
|
||||||
|
|
||||||
<div><strong>Email:</strong></div>
|
|
||||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['email'] ?? 'N/A'); ?></div>
|
|
||||||
|
|
||||||
<div><strong>Role:</strong></div>
|
|
||||||
<div><?php echo $GLOBALS['currentUser']['is_admin'] ? 'Administrator' : 'User'; ?></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-footer">
|
|
||||||
<button class="btn btn-primary" onclick="saveSettings()">Save Preferences</button>
|
|
||||||
<button class="btn btn-secondary" onclick="closeSettingsModal()">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Advanced Search Modal -->
|
|
||||||
<div class="settings-modal" id="advancedSearchModal" style="display: none;" onclick="closeOnAdvancedSearchBackdropClick(event)">
|
|
||||||
<div class="settings-content">
|
|
||||||
<div class="settings-header">
|
|
||||||
<h3>🔍 Advanced Search</h3>
|
|
||||||
<button class="close-settings" onclick="closeAdvancedSearch()">✗</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="advancedSearchForm" onsubmit="performAdvancedSearch(event)">
|
|
||||||
<div class="settings-body">
|
|
||||||
<!-- Saved Filters -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h4>╔══ Saved Filters ══╗</h4>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="saved-filters-select">Load Filter:</label>
|
|
||||||
<select id="saved-filters-select" class="setting-select" style="max-width: 70%;" onchange="loadSavedFilter()">
|
|
||||||
<option value="">-- Select a saved filter --</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row" style="justify-content: flex-end; gap: 0.5rem;">
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="saveCurrentFilter()" style="padding: 0.5rem 1rem;">💾 Save Current</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="deleteSavedFilter()" style="padding: 0.5rem 1rem;">🗑 Delete Selected</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search Text -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h4>╔══ Search Criteria ══╗</h4>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="adv-search-text">Search Text:</label>
|
|
||||||
<input type="text" id="adv-search-text" class="setting-select" style="max-width: 100%;" placeholder="Search in title, description...">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Date Ranges -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h4>╔══ Date Range ══╗</h4>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="adv-created-from">Created From:</label>
|
|
||||||
<input type="date" id="adv-created-from" class="setting-select">
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="adv-created-to">Created To:</label>
|
|
||||||
<input type="date" id="adv-created-to" class="setting-select">
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="adv-updated-from">Updated From:</label>
|
|
||||||
<input type="date" id="adv-updated-from" class="setting-select">
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="adv-updated-to">Updated To:</label>
|
|
||||||
<input type="date" id="adv-updated-to" class="setting-select">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status/Priority/Category/Type -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h4>╔══ Filters ══╗</h4>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="adv-status">Status:</label>
|
|
||||||
<select id="adv-status" class="setting-select" multiple size="4">
|
|
||||||
<option value="Open">Open</option>
|
|
||||||
<option value="Pending">Pending</option>
|
|
||||||
<option value="In Progress">In Progress</option>
|
|
||||||
<option value="Closed">Closed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="adv-priority-min">Priority Range:</label>
|
|
||||||
<select id="adv-priority-min" class="setting-select" style="max-width: 90px;">
|
|
||||||
<option value="">Any</option>
|
|
||||||
<option value="1">P1</option>
|
|
||||||
<option value="2">P2</option>
|
|
||||||
<option value="3">P3</option>
|
|
||||||
<option value="4">P4</option>
|
|
||||||
<option value="5">P5</option>
|
|
||||||
</select>
|
|
||||||
<span style="color: var(--terminal-green);">to</span>
|
|
||||||
<select id="adv-priority-max" class="setting-select" style="max-width: 90px;">
|
|
||||||
<option value="">Any</option>
|
|
||||||
<option value="1">P1</option>
|
|
||||||
<option value="2">P2</option>
|
|
||||||
<option value="3">P3</option>
|
|
||||||
<option value="4">P4</option>
|
|
||||||
<option value="5">P5</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Filters -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h4>╔══ Users ══╗</h4>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="adv-created-by">Created By:</label>
|
|
||||||
<select id="adv-created-by" class="setting-select">
|
|
||||||
<option value="">Any User</option>
|
|
||||||
<!-- Will be populated by JavaScript -->
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="adv-assigned-to">Assigned To:</label>
|
|
||||||
<select id="adv-assigned-to" class="setting-select">
|
|
||||||
<option value="">Any User</option>
|
|
||||||
<option value="unassigned">Unassigned</option>
|
|
||||||
<!-- Will be populated by JavaScript -->
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-footer">
|
|
||||||
<button type="submit" class="btn btn-primary">Search</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="resetAdvancedSearch()">Reset</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeAdvancedSearch()">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
|
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js"></script>
|
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,43 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
// This file contains the HTML template for a ticket
|
// This file contains the HTML template for a ticket
|
||||||
// It receives $ticket, $comments, and $timeline variables from the controller
|
// It receives $ticket and $comments variables from the controller
|
||||||
|
|
||||||
// Helper functions for timeline display
|
|
||||||
function getEventIcon($actionType) {
|
|
||||||
$icons = [
|
|
||||||
'create' => '✨',
|
|
||||||
'update' => '📝',
|
|
||||||
'comment' => '💬',
|
|
||||||
'view' => '👁️',
|
|
||||||
'assign' => '👤',
|
|
||||||
'status_change' => '🔄'
|
|
||||||
];
|
|
||||||
return $icons[$actionType] ?? '•';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAction($event) {
|
|
||||||
$actions = [
|
|
||||||
'create' => 'created this ticket',
|
|
||||||
'update' => 'updated this ticket',
|
|
||||||
'comment' => 'added a comment',
|
|
||||||
'view' => 'viewed this ticket',
|
|
||||||
'assign' => 'assigned this ticket',
|
|
||||||
'status_change' => 'changed the status'
|
|
||||||
];
|
|
||||||
return $actions[$event['action_type']] ?? $event['action_type'];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDetails($details, $actionType) {
|
|
||||||
if ($actionType === 'update' && is_array($details)) {
|
|
||||||
$changes = [];
|
|
||||||
foreach ($details as $field => $value) {
|
|
||||||
if ($field === 'old_value' || $field === 'new_value') continue;
|
|
||||||
$changes[] = "<strong>" . htmlspecialchars($field) . ":</strong> " . htmlspecialchars($value);
|
|
||||||
}
|
|
||||||
return implode(', ', $changes);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -48,10 +11,9 @@ function formatDetails($details, $actionType) {
|
|||||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||||
<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/toast.js"></script>
|
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js"></script>
|
|
||||||
<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 src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js"></script>
|
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Store ticket data in a global variable
|
// Store ticket data in a global variable
|
||||||
window.ticketData = {
|
window.ticketData = {
|
||||||
@@ -65,157 +27,37 @@ function formatDetails($details, $actionType) {
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="user-header">
|
<div class="ticket-container" data-priority="<?php echo $ticket["priority"]; ?>">
|
||||||
<div class="user-header-left">
|
<div class="ticket-header">
|
||||||
<a href="/" class="back-link">← Dashboard</a>
|
<h2><input type="text" class="editable title-input" value="<?php echo htmlspecialchars($ticket["title"]); ?>" data-field="title" disabled></h2>
|
||||||
</div>
|
<div class="ticket-subheader">
|
||||||
<div class="user-header-right">
|
<div class="ticket-id">UUID <?php echo $ticket['ticket_id']; ?></div>
|
||||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
<div class="header-controls">
|
||||||
<span class="user-name">👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<div class="status-priority-group">
|
||||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
<span id="statusDisplay" class="status-<?php echo str_replace(' ', '-', $ticket["status"]); ?>"><?php echo $ticket["status"]; ?></span>
|
||||||
<span class="admin-badge">Admin</span>
|
<span class="priority-indicator priority-<?php echo $ticket["priority"]; ?>">P<?php echo $ticket["priority"]; ?></span>
|
||||||
<?php endif; ?>
|
|
||||||
<button class="settings-icon" title="Settings (Alt+S)" onclick="openSettingsModal()">⚙</button>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ticket-container ascii-frame-outer" data-priority="<?php echo $ticket["priority"]; ?>">
|
|
||||||
<span class="bottom-left-corner">╚</span>
|
|
||||||
<span class="bottom-right-corner">╝</span>
|
|
||||||
|
|
||||||
<!-- SECTION 1: Ticket Header & Metadata -->
|
|
||||||
<div class="ascii-section-header">Ticket Information</div>
|
|
||||||
<div class="ascii-content">
|
|
||||||
<div class="ascii-frame-inner">
|
|
||||||
<div class="ticket-header">
|
|
||||||
<h2><div class="editable title-input" data-field="title" contenteditable="false"><?php echo htmlspecialchars($ticket["title"]); ?></div></h2>
|
|
||||||
<div class="ticket-subheader">
|
|
||||||
<div class="ticket-metadata">
|
|
||||||
<div class="ticket-id">UUID <?php echo $ticket['ticket_id']; ?></div>
|
|
||||||
<div class="ticket-user-info" style="font-size: 0.85rem; color: #666; margin-top: 0.25rem;">
|
|
||||||
<?php
|
|
||||||
$creator = $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System';
|
|
||||||
echo "Created by: <strong>" . htmlspecialchars($creator) . "</strong>";
|
|
||||||
if (!empty($ticket['created_at'])) {
|
|
||||||
echo " on " . date('M d, Y H:i', strtotime($ticket['created_at']));
|
|
||||||
}
|
|
||||||
if (!empty($ticket['updater_display_name']) || !empty($ticket['updater_username'])) {
|
|
||||||
$updater = $ticket['updater_display_name'] ?? $ticket['updater_username'];
|
|
||||||
echo " • Last updated by: <strong>" . htmlspecialchars($updater) . "</strong>";
|
|
||||||
if (!empty($ticket['updated_at'])) {
|
|
||||||
echo " on " . date('M d, Y H:i', strtotime($ticket['updated_at']));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
</div>
|
|
||||||
<div class="ticket-assignment" style="margin-top: 0.5rem;">
|
|
||||||
<label style="font-weight: 500; margin-right: 0.5rem;">Assigned to:</label>
|
|
||||||
<select id="assignedToSelect" class="assignment-select" style="padding: 0.25rem 0.5rem; border-radius: 0; border: 2px solid var(--terminal-green);">
|
|
||||||
<option value="">Unassigned</option>
|
|
||||||
<?php foreach ($allUsers as $user): ?>
|
|
||||||
<option value="<?php echo $user['user_id']; ?>"
|
|
||||||
<?php echo ($ticket['assigned_to'] == $user['user_id']) ? 'selected' : ''; ?>>
|
|
||||||
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Metadata Fields: Priority, Category, Type -->
|
|
||||||
<div class="ticket-metadata-fields" style="margin-top: 0.75rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem;">
|
|
||||||
<div class="metadata-field">
|
|
||||||
<label style="font-weight: 500; display: block; margin-bottom: 0.25rem; color: var(--terminal-amber); font-family: var(--font-mono); font-size: 0.85rem;">Priority:</label>
|
|
||||||
<select id="prioritySelect" class="metadata-select editable-metadata" disabled style="width: 100%; padding: 0.25rem 0.5rem; border-radius: 0; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
|
|
||||||
<option value="1" <?php echo $ticket['priority'] == 1 ? 'selected' : ''; ?>>P1 - Critical</option>
|
|
||||||
<option value="2" <?php echo $ticket['priority'] == 2 ? 'selected' : ''; ?>>P2 - High</option>
|
|
||||||
<option value="3" <?php echo $ticket['priority'] == 3 ? 'selected' : ''; ?>>P3 - Medium</option>
|
|
||||||
<option value="4" <?php echo $ticket['priority'] == 4 ? 'selected' : ''; ?>>P4 - Low</option>
|
|
||||||
<option value="5" <?php echo $ticket['priority'] == 5 ? 'selected' : ''; ?>>P5 - Lowest</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metadata-field">
|
|
||||||
<label style="font-weight: 500; display: block; margin-bottom: 0.25rem; color: var(--terminal-amber); font-family: var(--font-mono); font-size: 0.85rem;">Category:</label>
|
|
||||||
<select id="categorySelect" class="metadata-select editable-metadata" disabled style="width: 100%; padding: 0.25rem 0.5rem; border-radius: 0; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
|
|
||||||
<option value="Hardware" <?php echo $ticket['category'] == 'Hardware' ? 'selected' : ''; ?>>Hardware</option>
|
|
||||||
<option value="Software" <?php echo $ticket['category'] == 'Software' ? 'selected' : ''; ?>>Software</option>
|
|
||||||
<option value="Network" <?php echo $ticket['category'] == 'Network' ? 'selected' : ''; ?>>Network</option>
|
|
||||||
<option value="Security" <?php echo $ticket['category'] == 'Security' ? 'selected' : ''; ?>>Security</option>
|
|
||||||
<option value="General" <?php echo $ticket['category'] == 'General' ? 'selected' : ''; ?>>General</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metadata-field">
|
|
||||||
<label style="font-weight: 500; display: block; margin-bottom: 0.25rem; color: var(--terminal-amber); font-family: var(--font-mono); font-size: 0.85rem;">Type:</label>
|
|
||||||
<select id="typeSelect" class="metadata-select editable-metadata" disabled style="width: 100%; padding: 0.25rem 0.5rem; border-radius: 0; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
|
|
||||||
<option value="Maintenance" <?php echo $ticket['type'] == 'Maintenance' ? 'selected' : ''; ?>>Maintenance</option>
|
|
||||||
<option value="Install" <?php echo $ticket['type'] == 'Install' ? 'selected' : ''; ?>>Install</option>
|
|
||||||
<option value="Task" <?php echo $ticket['type'] == 'Task' ? 'selected' : ''; ?>>Task</option>
|
|
||||||
<option value="Upgrade" <?php echo $ticket['type'] == 'Upgrade' ? 'selected' : ''; ?>>Upgrade</option>
|
|
||||||
<option value="Issue" <?php echo $ticket['type'] == 'Issue' ? 'selected' : ''; ?>>Issue</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="header-controls">
|
|
||||||
<div class="status-priority-group">
|
|
||||||
<select id="statusSelect" class="editable status-select status-<?php echo str_replace(' ', '-', strtolower($ticket["status"])); ?>" data-field="status" onchange="updateTicketStatus()">
|
|
||||||
<option value="<?php echo $ticket['status']; ?>" selected>
|
|
||||||
<?php echo $ticket['status']; ?> (current)
|
|
||||||
</option>
|
|
||||||
<?php foreach ($allowedTransitions as $transition): ?>
|
|
||||||
<option value="<?php echo $transition['to_status']; ?>"
|
|
||||||
data-requires-comment="<?php echo $transition['requires_comment'] ? '1' : '0'; ?>"
|
|
||||||
data-requires-admin="<?php echo $transition['requires_admin'] ? '1' : '0'; ?>">
|
|
||||||
<?php echo $transition['to_status']; ?>
|
|
||||||
<?php if ($transition['requires_comment']): ?> *<?php endif; ?>
|
|
||||||
<?php if ($transition['requires_admin']): ?> (Admin)<?php endif; ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button id="editButton" class="btn" onclick="toggleEditMode()">Edit Ticket</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button id="editButton" class="btn" onclick="toggleEditMode()">Edit Ticket</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ticket-details">
|
||||||
<!-- DIVIDER -->
|
|
||||||
<div class="ascii-divider"></div>
|
|
||||||
|
|
||||||
<!-- SECTION 2: Tab Navigation -->
|
|
||||||
<div class="ascii-section-header">Content Sections</div>
|
|
||||||
<div class="ascii-content">
|
|
||||||
<div class="ticket-tabs">
|
<div class="ticket-tabs">
|
||||||
<button class="tab-btn active" onclick="showTab('description')">Description</button>
|
<button class="tab-btn active" onclick="showTab('description')">Description</button>
|
||||||
<button class="tab-btn" onclick="showTab('comments')">Comments</button>
|
<button class="tab-btn" onclick="showTab('comments')">Comments</button>
|
||||||
<button class="tab-btn" onclick="showTab('activity')">Activity</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- DIVIDER -->
|
<div id="description-tab" class="tab-content active">
|
||||||
<div class="ascii-divider"></div>
|
<div class="detail-group full-width">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea class="editable" data-field="description" disabled><?php echo $ticket["description"]; ?></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- SECTION 3: Tab Content Area -->
|
<div id="comments-tab" class="tab-content">
|
||||||
<div class="ascii-section-header">Content Display</div>
|
<div class="comments-section">
|
||||||
<div class="ascii-content">
|
<h2>Comments</h2>
|
||||||
<div class="ascii-frame-inner">
|
<div class="comment-form">
|
||||||
<div class="ticket-details">
|
|
||||||
<div id="description-tab" class="tab-content active">
|
|
||||||
<div class="ascii-subsection-header">Description</div>
|
|
||||||
<div class="detail-group full-width">
|
|
||||||
<label>Description</label>
|
|
||||||
<textarea class="editable" data-field="description" disabled><?php echo $ticket["description"]; ?></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="comments-tab" class="tab-content">
|
|
||||||
<div class="ascii-subsection-header">Comments Section</div>
|
|
||||||
<div class="comments-section">
|
|
||||||
<div class="ascii-frame-inner">
|
|
||||||
<h2>Add Comment</h2>
|
|
||||||
<div class="comment-form">
|
|
||||||
<textarea id="newComment" placeholder="Add a comment..."></textarea>
|
<textarea id="newComment" placeholder="Add a comment..."></textarea>
|
||||||
<div class="comment-controls">
|
<div class="comment-controls">
|
||||||
<div class="markdown-toggles">
|
<div class="markdown-toggles">
|
||||||
@@ -237,70 +79,35 @@ function formatDetails($details, $actionType) {
|
|||||||
<button onclick="addComment()" class="btn">Add Comment</button>
|
<button onclick="addComment()" class="btn">Add Comment</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="markdownPreview" class="markdown-preview" style="display: none;"></div>
|
<div id="markdownPreview" class="markdown-preview" style="display: none;"></div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Comment List in separate sub-frame -->
|
|
||||||
<div class="ascii-frame-inner">
|
|
||||||
<h2>Comment History</h2>
|
|
||||||
<div class="comments-list">
|
|
||||||
<?php
|
|
||||||
foreach ($comments as $comment) {
|
|
||||||
// Use display_name_formatted which falls back appropriately
|
|
||||||
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
|
|
||||||
echo "<div class='comment'>";
|
|
||||||
echo "<div class='comment-header'>";
|
|
||||||
echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>";
|
|
||||||
echo "<span class='comment-date'>" . date('M d, Y H:i', strtotime($comment['created_at'])) . "</span>";
|
|
||||||
echo "</div>";
|
|
||||||
echo "<div class='comment-text' " . ($comment['markdown_enabled'] ? "data-markdown" : "") . ">";
|
|
||||||
if ($comment['markdown_enabled']) {
|
|
||||||
// Markdown will be rendered by JavaScript
|
|
||||||
echo htmlspecialchars($comment['comment_text']);
|
|
||||||
} else {
|
|
||||||
// For non-markdown comments, convert line breaks to <br> and escape HTML
|
|
||||||
echo nl2br(htmlspecialchars($comment['comment_text']));
|
|
||||||
}
|
|
||||||
echo "</div>";
|
|
||||||
echo "</div>";
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="activity-tab" class="tab-content">
|
|
||||||
<div class="ascii-subsection-header">Activity Timeline</div>
|
|
||||||
<div class="timeline-container">
|
|
||||||
<?php if (empty($timeline)): ?>
|
|
||||||
<p>No activity recorded yet.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php foreach ($timeline as $event): ?>
|
|
||||||
<div class="timeline-event">
|
|
||||||
<div class="timeline-icon"><?php echo getEventIcon($event['action_type']); ?></div>
|
|
||||||
<div class="timeline-content">
|
|
||||||
<div class="timeline-header">
|
|
||||||
<strong><?php echo htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System'); ?></strong>
|
|
||||||
<span class="timeline-action"><?php echo formatAction($event); ?></span>
|
|
||||||
<span class="timeline-date"><?php echo date('M d, Y H:i', strtotime($event['created_at'])); ?></span>
|
|
||||||
</div>
|
|
||||||
<?php if (!empty($event['details'])): ?>
|
|
||||||
<div class="timeline-details">
|
|
||||||
<?php echo formatDetails($event['details'], $event['action_type']); ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="comments-list">
|
||||||
|
<?php
|
||||||
|
foreach ($comments as $comment) {
|
||||||
|
echo "<div class='comment'>";
|
||||||
|
echo "<div class='comment-header'>";
|
||||||
|
echo "<span class='comment-user'>" . htmlspecialchars($comment['user_name']) . "</span>";
|
||||||
|
echo "<span class='comment-date'>" . date('M d, Y H:i', strtotime($comment['created_at'])) . "</span>";
|
||||||
|
echo "</div>";
|
||||||
|
echo "<div class='comment-text'>";
|
||||||
|
if ($comment['markdown_enabled']) {
|
||||||
|
// For markdown comments, use JavaScript to render
|
||||||
|
echo "<script>document.write(marked.parse(" . json_encode($comment['comment_text']) . "))</script>";
|
||||||
|
} else {
|
||||||
|
// For non-markdown comments, convert line breaks to <br> and escape HTML
|
||||||
|
echo nl2br(htmlspecialchars($comment['comment_text']));
|
||||||
|
}
|
||||||
|
echo "</div>";
|
||||||
|
echo "</div>";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ticket-footer">
|
||||||
|
<button onclick="window.location.href='/'" class="btn back-btn">Back to Dashboard</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- END OUTER FRAME -->
|
|
||||||
<script>
|
<script>
|
||||||
// Initialize the ticket view
|
// Initialize the ticket view
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
@@ -323,126 +130,5 @@ function formatDetails($details, $actionType) {
|
|||||||
};
|
};
|
||||||
console.log('Ticket data loaded:', window.ticketData);
|
console.log('Ticket data loaded:', window.ticketData);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Settings Modal (same as dashboard) -->
|
|
||||||
<div class="settings-modal" id="settingsModal" style="display: none;" onclick="closeOnBackdropClick(event)">
|
|
||||||
<div class="settings-content">
|
|
||||||
<span class="bottom-left-corner">╚</span>
|
|
||||||
<span class="bottom-right-corner">╝</span>
|
|
||||||
|
|
||||||
<div class="settings-header">
|
|
||||||
<h3>⚙ System Preferences</h3>
|
|
||||||
<button class="close-settings" onclick="closeSettingsModal()">✗</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-body">
|
|
||||||
<!-- Display Preferences -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h4>╔══ Display Preferences ══╗</h4>
|
|
||||||
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="rowsPerPage">Rows per page:</label>
|
|
||||||
<select id="rowsPerPage" class="setting-select">
|
|
||||||
<option value="15">15</option>
|
|
||||||
<option value="25">25</option>
|
|
||||||
<option value="50">50</option>
|
|
||||||
<option value="100">100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="defaultFilters">Default status filters:</label>
|
|
||||||
<div class="checkbox-group">
|
|
||||||
<label><input type="checkbox" name="defaultFilters" value="Open" checked> Open</label>
|
|
||||||
<label><input type="checkbox" name="defaultFilters" value="Pending" checked> Pending</label>
|
|
||||||
<label><input type="checkbox" name="defaultFilters" value="In Progress" checked> In Progress</label>
|
|
||||||
<label><input type="checkbox" name="defaultFilters" value="Closed"> Closed</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="tableDensity">Table density:</label>
|
|
||||||
<select id="tableDensity" class="setting-select">
|
|
||||||
<option value="compact">Compact</option>
|
|
||||||
<option value="normal" selected>Normal</option>
|
|
||||||
<option value="comfortable">Comfortable</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notifications -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h4>╔══ Notifications ══╗</h4>
|
|
||||||
|
|
||||||
<div class="setting-row">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="notificationsEnabled" checked>
|
|
||||||
Enable browser notifications
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-row">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="soundEffects" checked>
|
|
||||||
Sound effects
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="toastDuration">Toast duration:</label>
|
|
||||||
<select id="toastDuration" class="setting-select">
|
|
||||||
<option value="3000" selected>3 seconds</option>
|
|
||||||
<option value="5000">5 seconds</option>
|
|
||||||
<option value="10000">10 seconds</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Keyboard Shortcuts -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h4>╔══ Keyboard Shortcuts ══╗</h4>
|
|
||||||
<div class="shortcuts-list">
|
|
||||||
<div class="shortcut-item">
|
|
||||||
<kbd>Ctrl/Cmd + E</kbd> <span>Toggle edit mode</span>
|
|
||||||
</div>
|
|
||||||
<div class="shortcut-item">
|
|
||||||
<kbd>Ctrl/Cmd + S</kbd> <span>Save changes</span>
|
|
||||||
</div>
|
|
||||||
<div class="shortcut-item">
|
|
||||||
<kbd>Alt + S</kbd> <span>Open settings</span>
|
|
||||||
</div>
|
|
||||||
<div class="shortcut-item">
|
|
||||||
<kbd>ESC</kbd> <span>Cancel/Close</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Info (Read-only) -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h4>╔══ User Information ══╗</h4>
|
|
||||||
<div class="user-info-grid">
|
|
||||||
<div><strong>Display Name:</strong></div>
|
|
||||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? 'N/A'); ?></div>
|
|
||||||
|
|
||||||
<div><strong>Username:</strong></div>
|
|
||||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['username']); ?></div>
|
|
||||||
|
|
||||||
<div><strong>Email:</strong></div>
|
|
||||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['email'] ?? 'N/A'); ?></div>
|
|
||||||
|
|
||||||
<div><strong>Role:</strong></div>
|
|
||||||
<div><?php echo $GLOBALS['currentUser']['is_admin'] ? 'Administrator' : 'User'; ?></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-footer">
|
|
||||||
<button class="btn btn-primary" onclick="saveSettings()">Save Preferences</button>
|
|
||||||
<button class="btn btn-secondary" onclick="closeSettingsModal()">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user