Compare commits

..

12 Commits

99 changed files with 5800 additions and 11125 deletions

727
Claude.md
View File

@@ -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

290
README.md
View File

@@ -1,274 +1,38 @@
# 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
- **Smart Dashboard**: Sortable columns, advanced filtering by status/priority/category/type
- **Full-Text Search**: Search across tickets, descriptions, and metadata
- **Ticket Assignment**: Assign tickets to specific users with "Assigned To" column
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators
- **Custom Categories**: Hardware, Software, Network, Security, General
- **Ticket Types**: Maintenance, Install, Task, Upgrade, Issue, Problem
- 📊 Clean dashboard interface with sortable columns
- 🎫 Customizable ticket creation and management
- 🔄 Real-time status updates and priority tracking
- 💬 Markdown-supported commenting system
- 🔔 Discord integration for notifications
- 📱 Mobile-responsive design
### 🔄 Workflow Management
- **Status Transitions**: Enforced workflow rules (Open → In Progress → Resolved → Closed)
- **Workflow Validation**: Server-side validation prevents invalid status changes
- **Admin Controls**: Certain transitions can require admin privileges
- **Comment Requirements**: Optional comment requirements for specific transitions
- **Activity Timeline**: Complete audit trail of all ticket changes
## Core Components
### 💬 Collaboration Features
- **Markdown Comments**: Full Markdown support with live preview
- **User Tracking**: Tracks who created, updated, and assigned tickets
- **Activity Timeline**: Shows all ticket events (creates, updates, assignments, comments)
- **Real-time Updates**: AJAX-powered updates without page refreshes
- **Dashboard**: View and filter tickets by status, priority, and type
- **Ticket Management**: Create, edit, and update ticket details
- **Priority Levels**: P1 (Critical) to P4 (Low) impact tracking
- **Categories**: Hardware, Software, Network, Security tracking
- **Comment System**: Markdown support for detailed documentation
### 🎫 Ticket Templates
- **Quick Creation**: Pre-configured templates for common issues
- **Default Templates**: Hardware Failure, Software Installation, Network Issue, Maintenance
- **Auto-fill**: Templates populate title, description, category, type, and priority
## Technical Details
### 👥 User Management & Authentication
- **SSO Integration**: Authelia authentication with LLDAP backend
- **Role-Based Access**: Admin and standard user roles
- **User Display Names**: Support for display names and usernames
- **Session Management**: Secure PHP session handling
- Backend: PHP with MySQL database
- Frontend: HTML5, CSS3, JavaScript
- Authentication: Environment-based configuration
- API: RESTful endpoints for ticket operations
### ⚡ Bulk Actions (Admin Only)
- **Bulk Close**: Close multiple tickets at once
- **Bulk Assign**: Assign multiple tickets to a user
- **Bulk Priority**: Change priority for multiple tickets
- **Operation Tracking**: All bulk operations logged in audit trail
## Configuration
### 🔔 Notifications
- **Discord Integration**: Webhook notifications for ticket creation and updates
- **Rich Embeds**: Color-coded priority indicators and ticket links
- **Change Tracking**: Detailed notification of what changed
### 🎨 User Interface
- **Dark Mode**: Full dark mode support with proper contrast
- **Responsive Design**: Works on desktop and mobile devices
- **Clean Layout**: Modern, intuitive interface
- **Hamburger Menu**: Quick access to ticket actions (priority, category, type)
## 🏗️ Technical Architecture
### Backend
- **Language**: PHP 7.4+
- **Database**: MariaDB/MySQL
- **Architecture**: MVC pattern with models, views, controllers
- **ORM**: Custom database abstraction layer
### Frontend
- **HTML5/CSS3**: Semantic markup with modern CSS
- **JavaScript**: Vanilla JS with Fetch API for AJAX
- **Markdown**: marked.js for Markdown rendering
- **Icons**: Unicode emoji icons
### Database Schema
- **tickets**: Core ticket data with user tracking
- **comments**: Markdown-supported comments
- **users**: User accounts synced from LLDAP
- **audit_log**: Complete audit trail with JSON details
- **status_transitions**: Workflow configuration
- **ticket_templates**: Reusable ticket templates
- **bulk_operations**: Tracking for bulk admin operations
### API Endpoints
- `/api/update_ticket.php` - Update ticket with workflow validation
- `/api/assign_ticket.php` - Assign ticket to user
- `/api/add_comment.php` - Add comment to ticket
- `/api/get_template.php` - Fetch ticket template
- `/api/get_users.php` - Get user list for assignments
- `/api/bulk_operation.php` - Perform bulk operations (admin only)
## 🚀 Setup & Configuration
### 1. Environment Configuration
Create `.env` file in project root:
1. Create `.env` file with database credentials:
```env
DB_HOST=10.10.10.50
DB_USER=tinkertickets
DB_PASS=your_password
DB_NAME=ticketing_system
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
```
### 2. Database Setup
Run migrations in order:
```bash
# Navigate to project directory
cd /root/code/tinker_tickets
# Run each migration
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/001_initial_schema.sql
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/007_add_ticket_assignment.sql
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/008_add_status_workflows.sql
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/009_add_ticket_templates.sql
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/010_add_bulk_operations.sql
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/011_remove_view_tracking.sql
```
### 3. Web Server Configuration
**Apache Configuration** (recommended):
```apache
<VirtualHost *:80>
ServerName t.lotusguild.org
DocumentRoot /root/code/tinker_tickets
<Directory /root/code/tinker_tickets>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
# Enable mod_rewrite for clean URLs
RewriteEngine On
RewriteBase /
# Route ticket URLs
RewriteRule ^ticket/([0-9]+)$ ticket.php?id=$1 [L,QSA]
# Route ticket create
RewriteRule ^ticket/create$ ticket.php?action=create [L,QSA]
</Directory>
</VirtualHost>
```
### 4. Authelia Integration
Tinker Tickets uses Authelia for SSO. User information is passed via headers:
- `Remote-User`: Username
- `Remote-Name`: Display name
- `Remote-Email`: Email address
- `Remote-Groups`: User groups (comma-separated)
Admin users must be in the `admins` group in LLDAP.
## 📁 Project Structure
```
tinker_tickets/
├── api/ # API endpoints
│ ├── add_comment.php
│ ├── assign_ticket.php
│ ├── bulk_operation.php
│ ├── get_template.php
│ ├── get_users.php
│ └── update_ticket.php
├── assets/ # Static assets
│ ├── css/
│ │ ├── dashboard.css
│ │ └── ticket.css
│ └── js/
│ ├── dashboard.js
│ └── ticket.js
├── config/ # Configuration
│ └── config.php
├── controllers/ # MVC Controllers
│ ├── DashboardController.php
│ └── TicketController.php
├── models/ # Data models
│ ├── AuditLogModel.php
│ ├── BulkOperationsModel.php
│ ├── CommentModel.php
│ ├── TemplateModel.php
│ ├── TicketModel.php
│ ├── UserModel.php
│ └── WorkflowModel.php
├── views/ # View templates
│ ├── CreateTicketView.php
│ ├── DashboardView.php
│ └── TicketView.php
├── migrations/ # Database migrations
│ ├── 001_initial_schema.sql
│ ├── 007_add_ticket_assignment.sql
│ ├── 008_add_status_workflows.sql
│ ├── 009_add_ticket_templates.sql
│ ├── 010_add_bulk_operations.sql
│ └── 011_remove_view_tracking.sql
├── index.php # Dashboard entry point
├── ticket.php # Ticket view/create entry point
└── .env # Environment configuration
```
## 🔐 Security Features
- **SQL Injection Prevention**: All queries use prepared statements
- **XSS Protection**: All output is properly escaped with `htmlspecialchars()`
- **Session Security**: Secure PHP session handling
- **Admin Validation**: Server-side admin checks for privileged operations
- **Workflow Enforcement**: Status transitions validated server-side
- **Audit Logging**: Complete audit trail of all actions
## 🎯 Workflow States
### Default Workflow
```
Open → In Progress → Resolved → Closed
↓ ↓ ↓
└─────────┴──────────┘
(can reopen)
```
### Workflow Configuration
Status transitions are defined in the `status_transitions` table:
- `from_status`: Current status
- `to_status`: Target status
- `requires_comment`: Whether transition requires a comment
- `requires_admin`: Whether transition requires admin privileges
- `is_active`: Whether transition is enabled
## 📝 Usage Examples
### Creating a Ticket
1. Click "New Ticket" button
2. Select template (optional) - auto-fills common fields
3. Fill in title, description, category, type, priority
4. Click "Create Ticket"
### Updating Ticket Status
1. Open ticket
2. Click status dropdown (next to priority badge)
3. Select allowed status (workflow-validated)
4. Confirm if comment is required
### Assigning Tickets
1. Open ticket or use dashboard bulk actions
2. Select user from "Assigned to" dropdown
3. Changes are auto-saved
### Bulk Operations (Admin Only)
1. Check multiple tickets on dashboard
2. Select bulk action (Close, Assign, Change Priority)
3. Complete operation
4. All actions are logged in audit trail
## 🔮 Roadmap
- ✅ Activity Timeline
- ✅ Ticket Assignment
- ✅ Status Transitions with Workflows
- ✅ Ticket Templates
- ✅ Bulk Actions (Admin Only)
- 🎨 **ANSI Art Redesign** (Next Priority)
- 🔗 Ticket Dependencies (blocks/blocked by)
- 📊 Custom Dashboard Widgets
- 🔧 Custom Fields per Category
## 🤝 Contributing
This is an internal tool for LotusGuild infrastructure management. For feature requests or bug reports, contact the infrastructure team.
## 📄 License
Internal use only - LotusGuild Infrastructure
## 🙏 Credits
Built with ❤️ for the LotusGuild community
Powered by PHP, MariaDB, and lots of coffee ☕
DB_HOST=localhost
DB_USER=username
DB_PASS=password
DB_NAME=database
DISCORD_WEBHOOK_URL=your_webhook_url
```

View File

@@ -10,29 +10,18 @@ try {
// Include required files with proper error handling
$configPath = dirname(__DIR__) . '/config/config.php';
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
if (!file_exists($configPath)) {
throw new Exception("Config file not found: $configPath");
}
if (!file_exists($commentModelPath)) {
throw new Exception("CommentModel file not found: $commentModelPath");
}
require_once $configPath;
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
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
@@ -40,40 +29,29 @@ try {
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
throw new Exception("Database connection failed: " . $conn->connect_error);
}
// Get POST data
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
throw new Exception("Invalid JSON data received");
}
$ticketId = $data['ticket_id'];
// Initialize models
// Initialize CommentModel directly
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// Add comment with user tracking
$result = $commentModel->addComment($ticketId, $data, $userId);
// Log comment creation to audit log
if ($result['success'] && isset($result['comment_id'])) {
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
}
// Add user display name to result for frontend
if ($result['success']) {
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
}
// Add comment
$result = $commentModel->addComment($ticketId, $data);
// Discard any unexpected output
ob_end_clean();
// Return JSON response
header('Content-Type: application/json');
echo json_encode($result);

View File

@@ -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]);

View File

@@ -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();
?>

View File

@@ -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"
]);
}

View File

@@ -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']);
}

View File

@@ -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]);

View File

@@ -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();
?>

View File

@@ -28,14 +28,7 @@ try {
foreach ($lines as $line) {
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
list($key, $value) = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Remove surrounding quotes if present
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
$value = substr($value, 1, -1);
}
$envVars[$key] = $value;
$envVars[trim($key)] = trim($value);
}
}
debug_log("Environment variables loaded");
@@ -44,46 +37,24 @@ try {
// Load models directly with absolute paths
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.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");
require_once $ticketModelPath;
require_once $commentModelPath;
require_once $auditLogModelPath;
require_once $workflowModelPath;
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
class ApiTicketController {
private $ticketModel;
private $commentModel;
private $auditLog;
private $workflowModel;
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->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->envVars = $envVars;
$this->userId = $userId;
$this->isAdmin = $isAdmin;
}
public function update($id, $data) {
debug_log("ApiTicketController->update called with ID: $id and data: " . json_encode($data));
@@ -127,38 +98,26 @@ try {
];
}
// Validate status transition using workflow model
if ($currentTicket['status'] !== $updateData['status']) {
$allowed = $this->workflowModel->isTransitionAllowed(
$currentTicket['status'],
$updateData['status'],
$this->isAdmin
);
if (!$allowed) {
return [
'success' => false,
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
];
}
// Validate status
$validStatuses = ['Open', 'Closed', 'In Progress', 'Pending'];
if (!in_array($updateData['status'], $validStatuses)) {
return [
'success' => false,
'error' => 'Invalid status value'
];
}
debug_log("Validation passed, calling ticketModel->updateTicket");
// Update ticket with user tracking
$result = $this->ticketModel->updateTicket($updateData, $this->userId);
// Update ticket
$result = $this->ticketModel->updateTicket($updateData);
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
if ($result) {
// Log ticket update to audit log
if ($this->userId) {
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
}
// Discord webhook disabled for updates - only send for new tickets
// $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
// Send Discord webhook notification
$this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
return [
'success' => true,
'status' => $updateData['status'],
@@ -300,7 +259,7 @@ try {
// Initialize controller
debug_log("Initializing controller");
$controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin);
$controller = new ApiTicketController($conn, $envVars);
debug_log("Controller initialized");
// Update ticket

View File

@@ -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

View File

@@ -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();
}
}
});

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// 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;

View File

@@ -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);

View File

@@ -18,12 +18,7 @@ function saveTicket() {
editables.forEach(field => {
if (field.dataset.field) {
// For contenteditable divs, use textContent/innerText; for inputs/textareas, use value
if (field.hasAttribute('contenteditable')) {
data[field.dataset.field] = field.textContent.trim();
} else {
data[field.dataset.field] = field.value;
}
data[field.dataset.field] = field.value;
}
});
@@ -56,7 +51,7 @@ function saveTicket() {
statusDisplay.className = `status-${data.status}`;
statusDisplay.textContent = data.status;
}
toast.success('Ticket updated successfully');
console.log('Ticket updated successfully');
} else {
console.error('Error in API response:', data.error || 'Unknown error');
}
@@ -68,47 +63,23 @@ function saveTicket() {
function toggleEditMode() {
const editButton = document.getElementById('editButton');
const titleField = document.querySelector('.title-input');
const descriptionField = document.querySelector('textarea[data-field="description"]');
const metadataFields = document.querySelectorAll('.editable-metadata');
const editables = document.querySelectorAll('.title-input, textarea[data-field="description"]');
const isEditing = editButton.classList.contains('active');
if (!isEditing) {
editButton.textContent = 'Save Changes';
editButton.classList.add('active');
// 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 => {
editables.forEach(field => {
field.disabled = false;
if (field.classList.contains('title-input')) {
field.focus();
}
});
} else {
saveTicket();
editButton.textContent = 'Edit Ticket';
editButton.classList.remove('active');
// Disable title
if (titleField) {
titleField.setAttribute('contenteditable', 'false');
}
// Disable description
if (descriptionField) {
descriptionField.disabled = true;
}
// Disable metadata fields
metadataFields.forEach(field => {
editables.forEach(field => {
field.disabled = true;
});
}
@@ -242,267 +213,44 @@ function toggleMarkdownMode() {
document.addEventListener('DOMContentLoaded', function() {
// Show description tab by default
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
const descriptionTextarea = document.querySelector('textarea[data-field="description"]');
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
autoResizeTextarea(descriptionTextarea);
autoResizeTextarea();
// Resize on input when in edit mode
descriptionTextarea.addEventListener('input', function() {
autoResizeTextarea(this);
});
descriptionTextarea.addEventListener('input', autoResizeTextarea);
}
// 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) {
// Hide all tab contents
const descriptionTab = document.getElementById('description-tab');
const commentsTab = document.getElementById('comments-tab');
const activityTab = document.getElementById('activity-tab');
if (!descriptionTab || !commentsTab) {
console.error('Tab elements not found');
return;
}
// Hide all tabs
// Hide both tabs
descriptionTab.style.display = 'none';
commentsTab.style.display = 'none';
if (activityTab) {
activityTab.style.display = 'none';
}
// Remove active class from all buttons
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
});
// Show selected tab and activate its button
document.getElementById(`${tabName}-tab`).style.display = 'block';
document.querySelector(`[onclick="showTab('${tabName}')"]`).classList.add('active');

View File

@@ -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)
};

View File

@@ -1,19 +1,7 @@
<?php
// Load environment variables
$envFile = __DIR__ . '/../.env';
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
// Strip quotes from values if present (parse_ini_file may include them)
if ($envVars) {
foreach ($envVars as $key => $value) {
if (is_string($value)) {
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
$envVars[$key] = substr($value, 1, -1);
}
}
}
}
$envVars = parse_ini_file($envFile);
// Global configuration
$GLOBALS['config'] = [

View File

@@ -1,67 +1,37 @@
<?php
require_once 'models/TicketModel.php';
require_once 'models/UserPreferencesModel.php';
class DashboardController {
private $ticketModel;
private $prefsModel;
private $conn;
public function __construct($conn) {
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->prefsModel = new UserPreferencesModel($conn);
}
public function index() {
// Get user ID for preferences
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
// Get query parameters
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
// 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'];
}
$limit = isset($_COOKIE['ticketsPerPage']) ? (int)$_COOKIE['ticketsPerPage'] : 15;
$sortColumn = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
$sortDirection = isset($_GET['dir']) ? $_GET['dir'] : 'desc';
$category = isset($_GET['category']) ? $_GET['category'] : null;
$type = isset($_GET['type']) ? $_GET['type'] : null;
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
// Handle status filtering with user preferences
$search = isset($_GET['search']) ? trim($_GET['search']) : null; // ADD THIS LINE
// Handle status filtering
$status = null;
if (isset($_GET['status']) && !empty($_GET['status'])) {
$status = $_GET['status'];
} else if (!isset($_GET['show_all'])) {
// Get default status filters from user preferences
if ($userId) {
$status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress');
} else {
// Default: show Open, Pending, and In Progress (exclude Closed)
$status = 'Open,Pending,In Progress';
}
// Default: show Open and In Progress (exclude Closed)
$status = 'Open,In Progress';
}
// If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null)
// Build advanced search filters array
$filters = [];
if (isset($_GET['created_from'])) $filters['created_from'] = $_GET['created_from'];
if (isset($_GET['created_to'])) $filters['created_to'] = $_GET['created_to'];
if (isset($_GET['updated_from'])) $filters['updated_from'] = $_GET['updated_from'];
if (isset($_GET['updated_to'])) $filters['updated_to'] = $_GET['updated_to'];
if (isset($_GET['priority_min'])) $filters['priority_min'] = $_GET['priority_min'];
if (isset($_GET['priority_max'])) $filters['priority_max'] = $_GET['priority_max'];
if (isset($_GET['created_by'])) $filters['created_by'] = $_GET['created_by'];
if (isset($_GET['assigned_to'])) $filters['assigned_to'] = $_GET['assigned_to'];
// Get tickets with pagination, sorting, search, and advanced filters
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters);
// Get tickets with pagination, sorting, and search
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search);
// Get categories and types for filters
$categories = $this->getCategories();

View File

@@ -2,27 +2,15 @@
// Use absolute paths for model includes
require_once dirname(__DIR__) . '/models/TicketModel.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 {
private $ticketModel;
private $commentModel;
private $auditLogModel;
private $userModel;
private $workflowModel;
private $templateModel;
private $envVars;
public function __construct($conn) {
$this->ticketModel = new TicketModel($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
$envPath = dirname(__DIR__) . '/.env';
@@ -32,54 +20,30 @@ class TicketController {
foreach ($lines as $line) {
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
list($key, $value) = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Remove surrounding quotes if present
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
$value = substr($value, 1, -1);
}
$this->envVars[$key] = $value;
$this->envVars[trim($key)] = trim($value);
}
}
}
}
public function view($id) {
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// Get ticket data
$ticket = $this->ticketModel->getTicketById($id);
if (!$ticket) {
header("HTTP/1.0 404 Not Found");
echo "Ticket not found";
return;
}
// Get comments for this ticket using CommentModel
$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
include dirname(__DIR__) . '/views/TicketView.php';
}
public function create() {
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// Check if form was submitted
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$ticketData = [
@@ -89,59 +53,45 @@ class TicketController {
'category' => $_POST['category'] ?? 'General',
'type' => $_POST['type'] ?? 'Issue'
];
// Validate input
if (empty($ticketData['title'])) {
$error = "Title is required";
$templates = $this->templateModel->getAllTemplates();
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
// Create ticket with user tracking
$result = $this->ticketModel->createTicket($ticketData, $userId);
// Create ticket
$result = $this->ticketModel->createTicket($ticketData);
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
$this->sendDiscordWebhook($result['ticket_id'], $ticketData);
// Redirect to the new ticket
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
exit;
} else {
$error = $result['error'];
$templates = $this->templateModel->getAllTemplates();
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
} else {
// Get all templates for the template selector
$templates = $this->templateModel->getAllTemplates();
// Display the create ticket form
include dirname(__DIR__) . '/views/CreateTicketView.php';
}
}
public function update($id) {
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// Check if this is an AJAX request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// For AJAX requests, get JSON data
$input = file_get_contents('php://input');
$data = json_decode($input, true);
// Add ticket_id to the data
$data['ticket_id'] = $id;
// Validate input data
if (empty($data['title'])) {
header('Content-Type: application/json');
@@ -151,15 +101,10 @@ class TicketController {
]);
return;
}
// Update ticket with user tracking
$result = $this->ticketModel->updateTicket($data, $userId);
// Log ticket update to audit log
if ($result && isset($GLOBALS['auditLog']) && $userId) {
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
}
// Update ticket
$result = $this->ticketModel->updateTicket($data);
// Return JSON response
header('Content-Type: application/json');
if ($result) {
@@ -189,7 +134,7 @@ class TicketController {
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
// Create ticket URL
$ticketUrl = "http://t.lotusguild.org/ticket/$ticketId";
$ticketUrl = "http://tinkertickets.local/ticket/$ticketId";
// Map priorities to Discord colors
$priorityColors = [

View File

@@ -5,6 +5,7 @@ error_reporting(E_ALL);
ini_set('display_errors', 1);
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
// Load environment variables with error check
$envFile = __DIR__ . '/.env';
if (!file_exists($envFile)) {
@@ -15,7 +16,7 @@ if (!file_exists($envFile)) {
exit;
}
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
$envVars = parse_ini_file($envFile);
if (!$envVars) {
echo json_encode([
'success' => false,
@@ -24,16 +25,6 @@ if (!$envVars) {
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
$conn = new mysqli(
$envVars['DB_HOST'],
@@ -50,22 +41,6 @@ if ($conn->connect_error) {
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
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -84,32 +59,22 @@ $data = json_decode($rawInput, true);
// Generate hash from stable components
function generateTicketHash($data) {
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches);
// Extract device name if present (matches /dev/sdX pattern)
preg_match('/\/dev\/sd[a-z]/', $data['title'], $deviceMatches);
$isDriveTicket = !empty($deviceMatches);
// Extract hostname from title [hostname][tags]...
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
$hostname = $hostMatches[1] ?? '';
// Detect issue category (not specific attribute values)
$issueCategory = '';
if (stripos($data['title'], 'SMART issues') !== false) {
$issueCategory = 'smart';
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) {
$issueCategory = 'storage';
} elseif (stripos($data['title'], 'memory') !== false) {
$issueCategory = 'memory';
} elseif (stripos($data['title'], 'cpu') !== false) {
$issueCategory = 'cpu';
} elseif (stripos($data['title'], 'network') !== false) {
$issueCategory = 'network';
}
// Extract SMART attribute types without their values
preg_match_all('/Warning ([^:]+)/', $data['title'], $smartMatches);
$smartAttributes = $smartMatches[1] ?? [];
// Build stable components with only static data
$stableComponents = [
'hostname' => $hostname,
'issue_category' => $issueCategory, // Generic category, not specific errors
'smart_attributes' => $smartAttributes,
'environment_tags' => array_filter(
explode('][', $data['title']),
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node'])
@@ -122,8 +87,9 @@ function generateTicketHash($data) {
}
// Sort arrays for consistent hashing
sort($stableComponents['smart_attributes']);
sort($stableComponents['environment_tags']);
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
}
@@ -156,9 +122,9 @@ if (!$data) {
// Generate ticket ID (9-digit format with leading zeros)
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
// Prepare insert query with created_by field
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
// Prepare insert query
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $conn->prepare($sql);
// First, store all values in variables
@@ -171,7 +137,7 @@ $type = $data['type'] ?? 'Issue';
// Then use the variables in bind_param
$stmt->bind_param(
"ssssssssi",
"ssssssss",
$ticket_id,
$title,
$description,
@@ -179,20 +145,10 @@ $stmt->bind_param(
$priority,
$category,
$type,
$ticketHash,
$userId
$ticketHash
);
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([
'success' => true,
'ticket_id' => $ticket_id,
@@ -224,7 +180,7 @@ $discord_data = [
"embeds" => [[
"title" => "New Ticket Created: #" . $ticket_id,
"description" => $title,
"url" => "http://t.lotusguild.org/ticket/" . $ticket_id,
"url" => "http://tinkertickets.local/ticket/" . $ticket_id,
"color" => $priorityColors[$priority],
"fields" => [
["name" => "Priority", "value" => $priority, "inline" => true],

View File

@@ -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";
?>

View File

@@ -1,8 +1,6 @@
<?php
// Main entry point for the application
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
$request = $_SERVER['REQUEST_URI'];
@@ -22,16 +20,6 @@ if (!str_starts_with($requestPath, '/api/')) {
if ($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

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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)
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,2 +0,0 @@
-- Remove all ticket view tracking records from audit_log
DELETE FROM audit_log WHERE action_type = 'view';

View File

@@ -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);

View File

@@ -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';
```

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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)
];
}
}

View File

@@ -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;
}
}

View File

@@ -7,58 +7,44 @@ class CommentModel {
}
public function getCommentsByTicketId($ticketId) {
$sql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ?
ORDER BY tc.created_at DESC";
$sql = "SELECT * FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at DESC";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar
$stmt->execute();
$result = $stmt->get_result();
$comments = [];
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;
}
return $comments;
}
public function addComment($ticketId, $commentData, $userId = null) {
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled)
VALUES (?, ?, ?, ?, ?)";
public function addComment($ticketId, $commentData) {
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
VALUES (?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
// Set default username (kept for backward compatibility)
// Set default username
$username = $commentData['user_name'] ?? 'User';
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
// Preserve line breaks in the comment text
$commentText = $commentData['comment_text'];
$stmt->bind_param(
"sissi",
"sssi",
$ticketId,
$userId,
$username,
$commentText,
$markdownEnabled
);
if ($stmt->execute()) {
$commentId = $this->conn->insert_id;
return [
'success' => true,
'comment_id' => $commentId,
'user_name' => $username,
'created_at' => date('M d, Y H:i'),
'markdown_enabled' => $markdownEnabled,

View File

@@ -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;
}
}
?>

View File

@@ -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;
}
}

View File

@@ -7,27 +7,16 @@ class TicketModel {
}
public function getTicketById($id) {
$sql = "SELECT t.*,
u_created.username as creator_username,
u_created.display_name as creator_display_name,
u_updated.username as updater_username,
u_updated.display_name as updater_display_name,
u_assigned.username as assigned_username,
u_assigned.display_name as assigned_display_name
FROM tickets t
LEFT JOIN users u_created ON t.created_by = u_created.user_id
LEFT JOIN users u_updated ON t.updated_by = u_updated.user_id
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
WHERE t.ticket_id = ?";
$sql = "SELECT * FROM tickets WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $id);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) {
return null;
}
return $result->fetch_assoc();
}
@@ -46,15 +35,15 @@ class TicketModel {
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
$offset = ($page - 1) * $limit;
// Build WHERE clause
$whereConditions = [];
$params = [];
$paramTypes = '';
// Status filtering
if ($status) {
$statuses = explode(',', $status);
@@ -63,7 +52,7 @@ class TicketModel {
$params = array_merge($params, $statuses);
$paramTypes .= str_repeat('s', count($statuses));
}
// Category filtering
if ($category) {
$categories = explode(',', $category);
@@ -72,7 +61,7 @@ class TicketModel {
$params = array_merge($params, $categories);
$paramTypes .= str_repeat('s', count($categories));
}
// Type filtering
if ($type) {
$types = explode(',', $type);
@@ -81,7 +70,7 @@ class TicketModel {
$params = array_merge($params, $types);
$paramTypes .= str_repeat('s', count($types));
}
// Search Functionality
if ($search && !empty($search)) {
$whereConditions[] = "(title LIKE ? OR description LIKE ? OR ticket_id LIKE ? OR category LIKE ? OR type LIKE ?)";
@@ -89,61 +78,6 @@ class TicketModel {
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
$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 = '';
if (!empty($whereConditions)) {
@@ -171,18 +105,8 @@ class TicketModel {
$totalResult = $countStmt->get_result();
$totalTickets = $totalResult->fetch_assoc()['total'];
// Get tickets with pagination and creator info
$sql = "SELECT t.*,
u_created.username as creator_username,
u_created.display_name as creator_display_name,
u_assigned.username as assigned_username,
u_assigned.display_name as assigned_display_name
FROM tickets t
LEFT JOIN users u_created ON t.created_by = u_created.user_id
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
$whereClause
ORDER BY $sortColumn $sortDirection
LIMIT ? OFFSET ?";
// Get tickets with pagination
$sql = "SELECT * FROM tickets $whereClause ORDER BY $sortColumn $sortDirection LIMIT ? OFFSET ?";
$stmt = $this->conn->prepare($sql);
// 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($message, $data = null) {
$log_message = date('Y-m-d H:i:s') . " - [Model] " . $message;
@@ -222,48 +146,46 @@ class TicketModel {
};
$debug("updateTicket called with data", $ticketData);
$sql = "UPDATE tickets SET
title = ?,
priority = ?,
status = ?,
$sql = "UPDATE tickets SET
title = ?,
priority = ?,
status = ?,
description = ?,
category = ?,
type = ?,
updated_by = ?,
updated_at = NOW()
updated_at = NOW()
WHERE ticket_id = ?";
$debug("SQL query", $sql);
try {
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
$debug("Prepare statement failed", $this->conn->error);
return false;
}
$debug("Binding parameters");
$stmt->bind_param(
"sissssii",
"sissssi",
$ticketData['title'],
$ticketData['priority'],
$ticketData['status'],
$ticketData['description'],
$ticketData['category'],
$ticketData['type'],
$updatedBy,
$ticketData['ticket_id']
);
$debug("Executing statement");
$result = $stmt->execute();
if (!$result) {
$debug("Execute failed", $stmt->error);
return false;
}
$debug("Update successful");
return true;
} catch (Exception $e) {
@@ -273,33 +195,32 @@ class TicketModel {
}
}
public function createTicket($ticketData, $createdBy = null) {
public function createTicket($ticketData) {
// Generate ticket ID (9-digit format with leading zeros)
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
// Set default values if not provided
$status = $ticketData['status'] ?? 'Open';
$priority = $ticketData['priority'] ?? '4';
$category = $ticketData['category'] ?? 'General';
$type = $ticketData['type'] ?? 'Issue';
$stmt->bind_param(
"sssssssi",
"sssssss",
$ticket_id,
$ticketData['title'],
$ticketData['description'],
$status,
$priority,
$category,
$type,
$createdBy
$type
);
if ($stmt->execute()) {
return [
'success' => true,
@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}
?>

View File

@@ -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
View 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?

View 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...
},
},
])
```

View 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,
},
},
])

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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;
}

View 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;

View 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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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

View 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;
}
}

View 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>,
)

View 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
}
]

View File

@@ -0,0 +1,8 @@
{
"title": "",
"status": "Open",
"priority": "4",
"category": "General",
"type": "Issue",
"description": ""
}

View 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."
}

View 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"
}
]

View File

@@ -0,0 +1,6 @@
export interface CommentData {
user_name: string;
comment_text: string;
created_at: string; // ISO date string
markdown_enabled: boolean;
}

View 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;
}

View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View 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']],
},
}),
],
})

View File

@@ -13,172 +13,72 @@
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a>
<div class="ticket-container">
<div class="ticket-header">
<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>
<?php if (isset($error)): ?>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- ERROR SECTION -->
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="error-message" style="color: var(--priority-1); border: 2px solid var(--priority-1); padding: 1rem; background: rgba(231, 76, 60, 0.1);">
<strong>⚠ Error:</strong> <?php echo $error; ?>
</div>
</div>
</div>
<div class="error-message"><?php echo $error; ?></div>
<?php endif; ?>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<form method="POST" action="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="ticket-form">
<!-- SECTION 2: Template Selection -->
<div class="ascii-section-header">Template Selection</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="templateSelect">Use Template (Optional)</label>
<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; ?>
<div class="ticket-details">
<div class="detail-group">
<label for="title">Title</label>
<input type="text" id="title" name="title" class="editable" required>
</div>
<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>
<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>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 3: Basic Information -->
<div class="ascii-section-header">Basic Information</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="title">Ticket Title *</label>
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
</div>
<div class="detail-group full-width">
<label for="description">Description</label>
<textarea id="description" name="description" class="editable" rows="15" required></textarea>
</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 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>
<!-- 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>
</div>
<!-- END OUTER FRAME -->
</body>
</html>
</html>

View File

@@ -10,212 +10,60 @@
<title>Ticket Dashboard</title>
<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">
<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>
</head>
<body data-categories='<?php echo json_encode($categories); ?>' data-types='<?php echo json_encode($types); ?>'>
<!-- Terminal Boot Sequence -->
<div id="boot-sequence" class="boot-overlay">
<pre id="boot-text"></pre>
</div>
<script>
function showBootSequence() {
const bootText = document.getElementById('boot-text');
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>
<div class="dashboard-header">
<h1>Tinker Tickets</h1>
<button onclick="window.location.href='<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create'" class="btn create-ticket">New Ticket</button> </div>
<div class="search-container">
<form method="GET" action="" class="search-form">
<!-- Preserve existing parameters -->
<?php if (isset($_GET['status'])): ?>
<input type="hidden" name="status" value="<?php echo htmlspecialchars($_GET['status']); ?>">
<?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>
<!-- Collapsible ASCII Banner -->
<div class="ascii-banner-wrapper collapsed">
<button class="banner-toggle" onclick="toggleBanner()">
<span class="toggle-icon">▼</span> ASCII Banner
</button>
<div id="ascii-banner-container" class="banner-content"></div>
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
<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>
<script>
function toggleBanner() {
const wrapper = document.querySelector('.ascii-banner-wrapper');
const icon = document.querySelector('.toggle-icon');
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>
<?php endif; ?>
<div class="table-controls">
<div class="ticket-count">
Total Tickets: <?php echo $totalTickets; ?>
</div>
<!-- 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="table-actions">
<div class="pagination">
<?php
$currentParams = $_GET;
// Previous page button
if ($page > 1) {
$currentParams['page'] = $page - 1;
@@ -239,55 +87,29 @@
}
?>
</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>
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
<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>
<table>
<thead>
<tr>
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
<th style="width: 40px;"><input type="checkbox" id="selectAllCheckbox" onclick="toggleSelectAll()"></th>
<?php endif; ?>
<?php
$currentSort = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
$currentDir = isset($_GET['dir']) ? $_GET['dir'] : 'desc';
$columns = [
'ticket_id' => 'Ticket ID',
'priority' => 'Priority',
'priority' => 'Priority',
'title' => 'Title',
'category' => 'Category',
'type' => 'Type',
'status' => 'Status',
'created_by' => 'Created By',
'assigned_to' => 'Assigned To',
'created_at' => 'Created',
'updated_at' => 'Updated'
];
@@ -307,289 +129,22 @@
<?php
if (count($tickets) > 0) {
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']}'>";
// 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><span>{$row['priority']}</span></td>";
echo "<td>" . htmlspecialchars($row['title']) . "</td>";
echo "<td>{$row['category']}</td>";
echo "<td>{$row['type']}</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['updated_at'])) . "</td>";
echo "</tr>";
}
} else {
$colspan = ($GLOBALS['currentUser']['is_admin'] ?? false) ? '11' : '10';
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>";
} else {
echo "<tr><td colspan='8'>No tickets found</td></tr>";
}
?>
</tbody>
</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>
</table>
</body>
</html>

View File

@@ -1,43 +1,6 @@
<?php
// This file contains the HTML template for a ticket
// It receives $ticket, $comments, and $timeline 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 '';
}
// It receives $ticket and $comments variables from the controller
?>
<!DOCTYPE html>
<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="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.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/ticket.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
// Store ticket data in a global variable
window.ticketData = {
@@ -65,157 +27,37 @@ function formatDetails($details, $actionType) {
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
<span class="admin-badge">Admin</span>
<?php endif; ?>
<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 class="ticket-container" data-priority="<?php echo $ticket["priority"]; ?>">
<div class="ticket-header">
<h2><input type="text" class="editable title-input" value="<?php echo htmlspecialchars($ticket["title"]); ?>" data-field="title" disabled></h2>
<div class="ticket-subheader">
<div class="ticket-id">UUID <?php echo $ticket['ticket_id']; ?></div>
<div class="header-controls">
<div class="status-priority-group">
<span id="statusDisplay" class="status-<?php echo str_replace(' ', '-', $ticket["status"]); ?>"><?php echo $ticket["status"]; ?></span>
<span class="priority-indicator priority-<?php echo $ticket["priority"]; ?>">P<?php echo $ticket["priority"]; ?></span>
</div>
<button id="editButton" class="btn" onclick="toggleEditMode()">Edit Ticket</button>
</div>
</div>
</div>
<!-- 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-details">
<div class="ticket-tabs">
<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('activity')">Activity</button>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<div id="description-tab" class="tab-content active">
<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 class="ascii-section-header">Content Display</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<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">
<div id="comments-tab" class="tab-content">
<div class="comments-section">
<h2>Comments</h2>
<div class="comment-form">
<textarea id="newComment" placeholder="Add a comment..."></textarea>
<div class="comment-controls">
<div class="markdown-toggles">
@@ -237,70 +79,35 @@ function formatDetails($details, $actionType) {
<button onclick="addComment()" class="btn">Add Comment</button>
</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 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 class="ticket-footer">
<button onclick="window.location.href='/'" class="btn back-btn">Back to Dashboard</button>
</div>
</div>
<!-- END OUTER FRAME -->
<script>
// Initialize the ticket view
document.addEventListener('DOMContentLoaded', function() {
@@ -323,126 +130,5 @@ function formatDetails($details, $actionType) {
};
console.log('Ticket data loaded:', window.ticketData);
</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>
</html>