Compare commits
43 Commits
f59913910f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ce95e555d5 | |||
| f45ec9b0f7 | |||
| 5a41ebf180 | |||
| e35401d54e | |||
| 913e294f9d | |||
| 28aa9e33ea | |||
| 31aa7d1b81 | |||
| 7695c6134c | |||
| 11f75fd823 | |||
| e179709fc3 | |||
| b03a9cfc8c | |||
| d44a530018 | |||
| 3c3b9d0a61 | |||
| 1046537429 | |||
| d8220da1e0 | |||
| 021c01b3d4 | |||
| 22cab10d5d | |||
| f0d7b9aa61 | |||
| 3493ed78f8 | |||
| 90c5b3ff71 | |||
| 84bea80abd | |||
| 2f9af856dc | |||
| 27075a62ee | |||
| dd8833ee2f | |||
| ab3e77a9ba | |||
| 68ff89b48c | |||
| 328c103460 | |||
| 21ef9154e9 | |||
| 4ecd72bc04 | |||
| 368ad9b48e | |||
| 3497c4cb47 | |||
| e756f8e0bb | |||
| fea7575ac8 | |||
| 6fbba3939f | |||
| f3c15e2582 | |||
| 51fa5a8a3c | |||
| 4a838b68ca | |||
| 5545328e53 | |||
| 8bb43c14db | |||
| 92544d60ce | |||
| 89a685a502 | |||
| d204756cfe | |||
| a34ca51223 |
283
Claude.md
283
Claude.md
@@ -1,283 +0,0 @@
|
||||
# Tinker Tickets - Project Documentation for AI Assistants
|
||||
|
||||
## Project Status (January 2026)
|
||||
|
||||
**Current Phase**: All core features implemented. System is production-ready.
|
||||
|
||||
**Completed Features**:
|
||||
- Activity Timeline, Ticket Assignment, Status Transitions with Workflows
|
||||
- Ticket Templates, Bulk Actions (Admin Only)
|
||||
- File Attachments, Ticket Dependencies, @Mentions in Comments
|
||||
- Recurring Tickets, Custom Fields, Advanced Search with Saved Filters
|
||||
- Export to CSV/JSON, API Key Management
|
||||
- Ticket Visibility Levels (public/internal/confidential)
|
||||
- Collapsible Sidebar, Kanban Card View, Inline Ticket Preview
|
||||
- Mobile Responsive Design, Ticket Linking in Comments
|
||||
- Admin Pages (Templates, Workflow, Recurring, Custom Fields, User Activity, Audit Log, API Keys)
|
||||
- Comment Edit/Delete (owner or admin can modify their comments)
|
||||
- Markdown Tables Support, Auto-linking URLs in Comments
|
||||
|
||||
**Security Features** (January 2026):
|
||||
- CSP with nonce-based script execution (no unsafe-inline)
|
||||
- IP-based rate limiting (prevents session bypass attacks)
|
||||
- Visibility checks on attachment downloads
|
||||
- Unique ticket ID generation with collision prevention
|
||||
- Internal visibility requires groups validation
|
||||
|
||||
## Design Decisions
|
||||
|
||||
**Not Planned / Out of Scope**:
|
||||
- Email integration - Discord webhooks are the notification method for this system
|
||||
- SLA management - Not required for internal infrastructure use
|
||||
- Time tracking - Out of scope for current requirements
|
||||
- OAuth2/External identity providers - Authelia is the only approved SSO method
|
||||
- GraphQL API - REST API is sufficient for current needs
|
||||
|
||||
**Wiki Documentation**: https://wiki.lotusguild.org/en/Services/service-tinker-tickets
|
||||
|
||||
## 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 retro terminal-style 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: nginx with PHP-FPM on production (10.10.10.45)
|
||||
- Authentication: Authelia SSO with LLDAP backend
|
||||
|
||||
**Production Environment:**
|
||||
- **Primary URL**: https://t.lotusguild.org
|
||||
- **Web Server**: nginx at 10.10.10.45 (`/var/www/html/tinkertickets`)
|
||||
- **Database**: MariaDB at 10.10.10.50 (`ticketing_system` database)
|
||||
- **Authentication**: Authelia provides SSO via headers
|
||||
- **Dev Environment**: `/root/code/tinker_tickets` (not production)
|
||||
|
||||
## Architecture
|
||||
|
||||
### MVC Pattern
|
||||
```
|
||||
Controllers → Models → Database
|
||||
↓
|
||||
Views
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
/tinker_tickets/
|
||||
├── api/ # API endpoints
|
||||
│ ├── add_comment.php # POST: Add comment
|
||||
│ ├── assign_ticket.php # POST: Assign ticket to user
|
||||
│ ├── bulk_operation.php # POST: Bulk operations - admin only
|
||||
│ ├── check_duplicates.php # GET: Check for duplicate tickets
|
||||
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
|
||||
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
|
||||
│ ├── download_attachment.php # GET: Download attachment (with visibility check)
|
||||
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
|
||||
│ ├── generate_api_key.php # POST: Generate API key (admin)
|
||||
│ ├── get_template.php # GET: Fetch ticket template
|
||||
│ ├── get_users.php # GET: Get user list
|
||||
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
|
||||
│ ├── manage_templates.php # CRUD: Templates (admin)
|
||||
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
|
||||
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
|
||||
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
|
||||
│ ├── update_comment.php # POST: Update comment (owner/admin)
|
||||
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
|
||||
│ └── upload_attachment.php # GET/POST: List or upload attachments
|
||||
├── assets/
|
||||
│ ├── css/
|
||||
│ │ ├── dashboard.css # Dashboard + terminal styling
|
||||
│ │ └── ticket.css # Ticket view styling
|
||||
│ ├── js/
|
||||
│ │ ├── advanced-search.js # Advanced search modal
|
||||
│ │ ├── ascii-banner.js # ASCII art banner
|
||||
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
|
||||
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts
|
||||
│ │ ├── markdown.js # Markdown rendering + ticket linking
|
||||
│ │ ├── settings.js # User preferences
|
||||
│ │ ├── ticket.js # Ticket + comments + visibility
|
||||
│ │ └── toast.js # Toast notifications
|
||||
│ └── images/
|
||||
│ └── favicon.png
|
||||
├── config/
|
||||
│ └── config.php # Config + .env loading
|
||||
├── controllers/
|
||||
│ ├── DashboardController.php # Dashboard with stats + filters
|
||||
│ └── TicketController.php # Ticket CRUD + timeline + visibility
|
||||
├── cron/
|
||||
│ └── create_recurring_tickets.php # Process recurring ticket schedules
|
||||
├── helpers/
|
||||
│ └── ResponseHelper.php # Standardized JSON responses
|
||||
├── middleware/
|
||||
│ ├── AuthMiddleware.php # Authelia SSO integration
|
||||
│ ├── CsrfMiddleware.php # CSRF protection
|
||||
│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
|
||||
│ └── SecurityHeadersMiddleware.php # CSP with nonces, security headers
|
||||
├── models/
|
||||
│ ├── ApiKeyModel.php # API key generation/validation
|
||||
│ ├── AuditLogModel.php # Audit logging + timeline
|
||||
│ ├── BulkOperationsModel.php # Bulk operations tracking
|
||||
│ ├── CommentModel.php # Comment data access
|
||||
│ ├── CustomFieldModel.php # Custom field definitions/values
|
||||
│ ├── DependencyModel.php # Ticket dependencies
|
||||
│ ├── RecurringTicketModel.php # Recurring ticket schedules
|
||||
│ ├── StatsModel.php # Dashboard statistics
|
||||
│ ├── TemplateModel.php # Ticket templates
|
||||
│ ├── TicketModel.php # Ticket CRUD + assignment + visibility
|
||||
│ ├── UserModel.php # User management + groups
|
||||
│ ├── UserPreferencesModel.php # User preferences
|
||||
│ └── WorkflowModel.php # Status transition workflows
|
||||
├── scripts/
|
||||
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
|
||||
│ └── create_dependencies_table.php # Create ticket_dependencies table
|
||||
├── uploads/ # File attachment storage
|
||||
├── views/
|
||||
│ ├── admin/
|
||||
│ │ ├── ApiKeysView.php # API key management
|
||||
│ │ ├── AuditLogView.php # Audit log browser
|
||||
│ │ ├── CustomFieldsView.php # Custom field management
|
||||
│ │ ├── RecurringTicketsView.php # Recurring ticket management
|
||||
│ │ ├── TemplatesView.php # Template management
|
||||
│ │ ├── UserActivityView.php # User activity report
|
||||
│ │ └── WorkflowDesignerView.php # Workflow transition designer
|
||||
│ ├── CreateTicketView.php # Ticket creation with visibility
|
||||
│ ├── DashboardView.php # Dashboard with kanban + sidebar
|
||||
│ └── TicketView.php # Ticket view with visibility editing
|
||||
├── .env # Environment variables (GITIGNORED)
|
||||
├── Claude.md # This file
|
||||
├── README.md # User documentation
|
||||
└── index.php # Main router
|
||||
```
|
||||
|
||||
## Admin Pages
|
||||
|
||||
All admin pages are accessible via the **Admin dropdown** in the dashboard header (for admin users only).
|
||||
|
||||
| Route | Description |
|
||||
|-------|-------------|
|
||||
| `/admin/templates` | Create and edit ticket templates |
|
||||
| `/admin/workflow` | Visual workflow transition designer |
|
||||
| `/admin/recurring-tickets` | Manage recurring ticket schedules |
|
||||
| `/admin/custom-fields` | Define custom fields per category |
|
||||
| `/admin/user-activity` | View per-user activity statistics |
|
||||
| `/admin/audit-log` | Browse all audit log entries |
|
||||
| `/admin/api-keys` | Generate and manage API keys |
|
||||
|
||||
## Database Schema
|
||||
|
||||
**Database**: `ticketing_system` at 10.10.10.50
|
||||
**User**: `tinkertickets`
|
||||
|
||||
### Core Tables
|
||||
|
||||
| Table | Description |
|
||||
|-------|-------------|
|
||||
| `tickets` | Core ticket data with assignment, visibility, and tracking |
|
||||
| `ticket_comments` | Markdown-supported comments with user_id reference |
|
||||
| `ticket_attachments` | File attachment metadata |
|
||||
| `ticket_dependencies` | Ticket relationships (blocks, blocked_by, relates_to, duplicates) |
|
||||
| `users` | User accounts synced from LLDAP (includes groups) |
|
||||
| `user_preferences` | User settings and preferences |
|
||||
| `audit_log` | Complete audit trail with indexed queries |
|
||||
| `status_transitions` | Workflow configuration |
|
||||
| `ticket_templates` | Reusable ticket templates |
|
||||
| `recurring_tickets` | Scheduled ticket definitions |
|
||||
| `custom_field_definitions` | Custom field schemas per category |
|
||||
| `custom_field_values` | Custom field data per ticket |
|
||||
| `saved_filters` | User-saved dashboard filters |
|
||||
| `bulk_operations` | Bulk operation tracking |
|
||||
| `api_keys` | API key storage with hashed keys |
|
||||
|
||||
### tickets Table Key Columns
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `ticket_id` | varchar(9) | Unique 9-digit identifier |
|
||||
| `visibility` | enum | 'public', 'internal', 'confidential' |
|
||||
| `visibility_groups` | varchar(500) | Comma-separated group names (for internal) |
|
||||
| `created_by` | int | Foreign key to users |
|
||||
| `assigned_to` | int | Foreign key to users (nullable) |
|
||||
| `updated_by` | int | Foreign key to users |
|
||||
| `priority` | int | 1-5 (1=Critical, 5=Minimal) |
|
||||
| `status` | varchar(20) | Open, Pending, In Progress, Closed |
|
||||
|
||||
### Indexed Columns (for performance)
|
||||
|
||||
- `tickets`: ticket_id (unique), status, priority, created_at, created_by, assigned_to, visibility
|
||||
- `audit_log`: user_id, action_type, entity_type, created_at
|
||||
|
||||
## Dashboard Features
|
||||
|
||||
- **View Toggle**: Switch between Table view and Kanban card view
|
||||
- **Collapsible Sidebar**: Click arrow to collapse/expand filter sidebar
|
||||
- **Stats Widgets**: Clickable cards for quick filtering
|
||||
- **Inline Ticket Preview**: Hover over ticket IDs for 300ms to see preview popup
|
||||
- **Sortable Columns**: Click headers to sort
|
||||
- **Advanced Search**: Date ranges, priority ranges, user filters
|
||||
- **Saved Filters**: Save and load custom filter combinations
|
||||
- **Bulk Actions** (admin): Select multiple tickets for bulk operations
|
||||
- **Export**: Export selected tickets to CSV or JSON
|
||||
|
||||
## Ticket Visibility Levels
|
||||
|
||||
- **Public**: All authenticated users can view
|
||||
- **Internal**: Only users in specified groups can view (groups required)
|
||||
- **Confidential**: Only creator, assignee, and admins can view
|
||||
|
||||
**Important**: Internal visibility requires at least one group to be specified. Attempting to create/update a ticket with internal visibility but no groups will fail validation.
|
||||
|
||||
## Important Notes for AI Assistants
|
||||
|
||||
1. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
|
||||
2. **API auth**: Check `$_SESSION['user']['user_id']` exists
|
||||
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
|
||||
4. **Config path**: `config/config.php` (not `config/db.php`)
|
||||
5. **Comments table**: `ticket_comments` (not `comments`)
|
||||
6. **CSRF**: Required for POST/DELETE requests via `X-CSRF-Token` header
|
||||
7. **Cache busting**: Use `?v=YYYYMMDD` query params on JS/CSS files
|
||||
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
|
||||
9. **User groups**: Stored in `users.groups` as comma-separated values
|
||||
10. **API routing**: All API endpoints must be added to `index.php` router
|
||||
11. **Session in APIs**: RateLimitMiddleware starts session; don't call session_start() again
|
||||
12. **Database collation**: Use `utf8mb4_general_ci` (not unicode_ci) for new tables
|
||||
13. **Discord webhook URLs**: Set `APP_DOMAIN` in `.env` for correct ticket URLs in Discord notifications
|
||||
13. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
|
||||
14. **CSP Nonces**: All inline scripts require `nonce="<?php echo $nonce; ?>"` attribute
|
||||
15. **Visibility validation**: Internal visibility requires groups; code validates this
|
||||
16. **Rate limiting**: Both session-based AND IP-based limits are enforced
|
||||
|
||||
## File Reference Quick Guide
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.php` | Main router for all routes |
|
||||
| `api/update_ticket.php` | Ticket updates with workflow + visibility |
|
||||
| `api/download_attachment.php` | File downloads with visibility check |
|
||||
| `api/bulk_operation.php` | Bulk operations with visibility filtering |
|
||||
| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation |
|
||||
| `models/ApiKeyModel.php` | API key generation and validation |
|
||||
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with nonce generation |
|
||||
| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting |
|
||||
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions |
|
||||
| `assets/js/ticket.js` | Ticket UI, visibility editing |
|
||||
| `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) |
|
||||
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar |
|
||||
|
||||
## Security Implementations
|
||||
|
||||
| Feature | Implementation |
|
||||
|---------|---------------|
|
||||
| SQL Injection | All queries use prepared statements with parameter binding |
|
||||
| XSS Prevention | HTML escaped in markdown parser, CSP with nonces |
|
||||
| CSRF Protection | Token-based with constant-time comparison |
|
||||
| Session Security | Fixation prevention, secure cookies, timeout |
|
||||
| Rate Limiting | Session-based + IP-based (file storage) |
|
||||
| File Security | Path traversal prevention, MIME validation |
|
||||
| Visibility | Enforced on views, downloads, and bulk operations |
|
||||
|
||||
## Repository
|
||||
|
||||
- **Gitea**: https://code.lotusguild.org/LotusGuild/tinker_tickets
|
||||
- **Production**: https://t.lotusguild.org
|
||||
- **Wiki**: https://wiki.lotusguild.org/en/Services/service-tinker-tickets
|
||||
320
README.md
320
README.md
@@ -3,6 +3,22 @@
|
||||
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
|
||||
|
||||
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
|
||||
**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
|
||||
|
||||
## Styling & Layout
|
||||
|
||||
Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
|
||||
|
||||
- [`web_template/README.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/README.md) — full component reference, CSS variables, JS API
|
||||
- [`web_template/base.css`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.css) — unified CSS (`.lt-*` classes)
|
||||
- [`web_template/base.js`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.js) — `window.lt` utilities (toast, modal, CSRF, fetch helpers)
|
||||
- [`web_template/php/layout.php`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/php/layout.php) — PHP base layout template
|
||||
|
||||
**Key conventions:**
|
||||
- All `.lt-*` CSS classes come from `base.css` — do not duplicate them in `assets/css/`
|
||||
- All `lt.*` JS utilities come from `base.js` — use `lt.toast`, `lt.modal`, `lt.api`, etc.
|
||||
- CSP nonces: every `<script>` tag needs `nonce="<?php echo $nonce; ?>"`
|
||||
- CSRF: inject `window.CSRF_TOKEN` via the nonce-protected inline script block; `lt.api.*` adds the header automatically
|
||||
|
||||
## Design Decisions
|
||||
|
||||
@@ -17,7 +33,7 @@ The following features are intentionally **not planned** for this system:
|
||||
### Dashboard & Ticket Management
|
||||
- **View Modes**: Toggle between Table view and Kanban card view
|
||||
- **Collapsible Sidebar**: Click the arrow to collapse/expand the filter sidebar
|
||||
- **Inline Ticket Preview**: Hover over ticket IDs for a quick preview popup
|
||||
- **Inline Ticket Preview**: Hover over ticket IDs for a quick preview popup (300ms delay)
|
||||
- **Stats Widgets**: Clickable cards for quick filtering (Open, Critical, Unassigned, Today's tickets)
|
||||
- **Full-Text Search**: Search across tickets, descriptions, and metadata
|
||||
- **Advanced Search**: Date ranges, priority ranges, user filters with saved filter support
|
||||
@@ -30,7 +46,7 @@ The following features are intentionally **not planned** for this system:
|
||||
|
||||
### Ticket Visibility Levels
|
||||
- **Public**: All authenticated users can view the ticket
|
||||
- **Internal**: Only users in specified groups can view the ticket
|
||||
- **Internal**: Only users in specified groups can view the ticket (at least one group required)
|
||||
- **Confidential**: Only the creator, assignee, and admins can view the ticket
|
||||
|
||||
### Workflow Management
|
||||
@@ -102,7 +118,7 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
||||
### Notifications
|
||||
- **Discord Integration**: Webhook notifications for ticket creation and updates
|
||||
- **Rich Embeds**: Color-coded priority indicators and ticket links
|
||||
- **Dynamic URLs**: Ticket links adapt to the server hostname
|
||||
- **Dynamic URLs**: Ticket links adapt to the server hostname (set `APP_DOMAIN` in `.env`)
|
||||
|
||||
### Keyboard Shortcuts
|
||||
| Shortcut | Action |
|
||||
@@ -110,6 +126,11 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
||||
| `Ctrl/Cmd + E` | Toggle edit mode (ticket page) |
|
||||
| `Ctrl/Cmd + S` | Save changes (ticket page) |
|
||||
| `Ctrl/Cmd + K` | Focus search box (dashboard) |
|
||||
| `N` | New ticket (dashboard) |
|
||||
| `J` / `K` | Next / previous row (dashboard table) |
|
||||
| `Enter` | Open selected ticket (dashboard) |
|
||||
| `G` then `D` | Go to dashboard |
|
||||
| `1`–`4` | Quick status change (ticket page) |
|
||||
| `ESC` | Cancel edit / close modal |
|
||||
| `?` | Show keyboard shortcuts help |
|
||||
|
||||
@@ -129,6 +150,7 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
||||
- **Language**: PHP 7.4+
|
||||
- **Database**: MariaDB/MySQL
|
||||
- **Architecture**: MVC pattern with models, views, controllers
|
||||
- **Authentication**: Authelia SSO with LLDAP backend
|
||||
|
||||
### Frontend
|
||||
- **HTML5/CSS3**: Semantic markup with retro terminal styling
|
||||
@@ -138,6 +160,7 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
||||
- **Mobile Responsive**: Touch-friendly controls, responsive layouts
|
||||
|
||||
### Database Tables
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `tickets` | Core ticket data with visibility |
|
||||
@@ -153,9 +176,29 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
||||
| `custom_field_definitions` | Custom field schemas |
|
||||
| `custom_field_values` | Custom field data |
|
||||
| `saved_filters` | Saved filter combinations |
|
||||
| `api_keys` | API key storage |
|
||||
| `bulk_operations` | Bulk operation tracking |
|
||||
| `api_keys` | API key storage with hashed keys |
|
||||
|
||||
#### `tickets` Table Key Columns
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `ticket_id` | varchar(9) | Unique 9-digit identifier |
|
||||
| `visibility` | enum | `public`, `internal`, `confidential` |
|
||||
| `visibility_groups` | varchar(500) | Comma-separated group names (for internal) |
|
||||
| `created_by` | int | Foreign key to users |
|
||||
| `assigned_to` | int | Foreign key to users (nullable) |
|
||||
| `updated_by` | int | Foreign key to users |
|
||||
| `priority` | int | 1–5 (1=Critical, 5=Minimal) |
|
||||
| `status` | varchar(20) | Open, Pending, In Progress, Closed |
|
||||
|
||||
#### Indexed Columns (performance)
|
||||
|
||||
- `tickets`: `ticket_id` (unique), `status`, `priority`, `created_at`, `created_by`, `assigned_to`, `visibility`
|
||||
- `audit_log`: `user_id`, `action_type`, `entity_type`, `created_at`
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
|
||||
@@ -169,75 +212,104 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
||||
| `/api/export_tickets.php` | GET | Export tickets to CSV/JSON |
|
||||
| `/api/generate_api_key.php` | POST | Generate API key (admin) |
|
||||
| `/api/revoke_api_key.php` | POST | Revoke API key (admin) |
|
||||
|
||||
## Setup & Configuration
|
||||
|
||||
### 1. Environment Configuration
|
||||
|
||||
Copy the example file and edit with your values:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Required environment variables:
|
||||
```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/...
|
||||
APP_DOMAIN=t.lotusguild.org
|
||||
TIMEZONE=America/New_York
|
||||
```
|
||||
|
||||
**Note**: `APP_DOMAIN` is required for Discord webhook ticket links to work correctly. Without it, links will default to localhost.
|
||||
|
||||
### 2. Cron Jobs
|
||||
|
||||
Add to crontab for recurring tickets:
|
||||
```bash
|
||||
# Run every hour to create scheduled recurring tickets
|
||||
0 * * * * php /var/www/html/tinkertickets/cron/create_recurring_tickets.php
|
||||
```
|
||||
|
||||
### 3. File Uploads
|
||||
|
||||
Ensure the `uploads/` directory exists and is writable:
|
||||
```bash
|
||||
mkdir -p /var/www/html/tinkertickets/uploads
|
||||
chown www-data:www-data /var/www/html/tinkertickets/uploads
|
||||
chmod 755 /var/www/html/tinkertickets/uploads
|
||||
```
|
||||
|
||||
### 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 `admin` group in LLDAP.
|
||||
| `/api/delete_comment.php` | POST | Delete comment (owner/admin) |
|
||||
| `/api/update_comment.php` | POST | Update comment (owner/admin) |
|
||||
| `/api/delete_attachment.php` | POST/DELETE | Delete attachment |
|
||||
| `/api/download_attachment.php` | GET | Download attachment (visibility checked) |
|
||||
| `/api/check_duplicates.php` | GET | Check for duplicate tickets |
|
||||
| `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) |
|
||||
| `/api/manage_templates.php` | CRUD | Templates (admin) |
|
||||
| `/api/manage_workflows.php` | CRUD | Workflow rules (admin) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
tinker_tickets/
|
||||
├── api/ # API endpoints
|
||||
├── assets/ # Static assets (CSS, JS)
|
||||
├── config/ # Configuration
|
||||
├── controllers/ # MVC Controllers
|
||||
├── cron/ # Scheduled task scripts
|
||||
├── helpers/ # Utility classes
|
||||
├── middleware/ # Request middleware
|
||||
├── models/ # Data models
|
||||
├── scripts/ # Maintenance scripts
|
||||
├── uploads/ # File upload storage
|
||||
├── views/ # View templates
|
||||
│ └── admin/ # Admin panel views
|
||||
├── index.php # Main router
|
||||
└── .env # Environment configuration
|
||||
├── api/
|
||||
│ ├── add_comment.php # POST: Add comment
|
||||
│ ├── assign_ticket.php # POST: Assign ticket to user
|
||||
│ ├── bulk_operation.php # POST: Bulk operations (admin only)
|
||||
│ ├── check_duplicates.php # GET: Check for duplicate tickets
|
||||
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
|
||||
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
|
||||
│ ├── download_attachment.php # GET: Download with visibility check
|
||||
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
|
||||
│ ├── generate_api_key.php # POST: Generate API key (admin)
|
||||
│ ├── get_template.php # GET: Fetch ticket template
|
||||
│ ├── get_users.php # GET: Get user list
|
||||
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
|
||||
│ ├── manage_templates.php # CRUD: Templates (admin)
|
||||
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
|
||||
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
|
||||
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
|
||||
│ ├── update_comment.php # POST: Update comment (owner/admin)
|
||||
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
|
||||
│ └── upload_attachment.php # GET/POST: List or upload attachments
|
||||
├── assets/
|
||||
│ ├── css/
|
||||
│ │ ├── base.css # LotusGuild Terminal Design System (symlinked from web_template)
|
||||
│ │ ├── dashboard.css # Dashboard + terminal styling
|
||||
│ │ └── ticket.css # Ticket view styling
|
||||
│ ├── js/
|
||||
│ │ ├── advanced-search.js # Advanced search modal
|
||||
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
|
||||
│ │ ├── base.js # LotusGuild JS utilities — window.lt (symlinked from web_template)
|
||||
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
|
||||
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
|
||||
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
|
||||
│ │ ├── settings.js # User preferences
|
||||
│ │ ├── ticket.js # Ticket + comments + visibility
|
||||
│ │ ├── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
|
||||
│ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
|
||||
│ └── images/
|
||||
│ └── favicon.png
|
||||
├── config/
|
||||
│ └── config.php # Config + .env loading
|
||||
├── controllers/
|
||||
│ ├── DashboardController.php # Dashboard with stats + filters
|
||||
│ └── TicketController.php # Ticket CRUD + timeline + visibility
|
||||
├── cron/
|
||||
│ └── create_recurring_tickets.php # Process recurring ticket schedules
|
||||
├── helpers/
|
||||
│ └── ResponseHelper.php # Standardized JSON responses
|
||||
├── middleware/
|
||||
│ ├── AuthMiddleware.php # Authelia SSO integration
|
||||
│ ├── CsrfMiddleware.php # CSRF protection
|
||||
│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
|
||||
│ └── SecurityHeadersMiddleware.php # CSP with nonces, security headers
|
||||
├── models/
|
||||
│ ├── ApiKeyModel.php # API key generation/validation
|
||||
│ ├── AuditLogModel.php # Audit logging + timeline
|
||||
│ ├── BulkOperationsModel.php # Bulk operations tracking
|
||||
│ ├── CommentModel.php # Comment data access
|
||||
│ ├── CustomFieldModel.php # Custom field definitions/values
|
||||
│ ├── DependencyModel.php # Ticket dependencies
|
||||
│ ├── RecurringTicketModel.php # Recurring ticket schedules
|
||||
│ ├── StatsModel.php # Dashboard statistics
|
||||
│ ├── TemplateModel.php # Ticket templates
|
||||
│ ├── TicketModel.php # Ticket CRUD + visibility + collision-safe IDs
|
||||
│ ├── UserModel.php # User management + groups
|
||||
│ ├── UserPreferencesModel.php # User preferences
|
||||
│ └── WorkflowModel.php # Status transition workflows
|
||||
├── scripts/
|
||||
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
|
||||
│ └── create_dependencies_table.php # Create ticket_dependencies table
|
||||
├── uploads/ # File attachment storage
|
||||
├── views/
|
||||
│ ├── admin/
|
||||
│ │ ├── ApiKeysView.php # API key management
|
||||
│ │ ├── AuditLogView.php # Audit log browser
|
||||
│ │ ├── CustomFieldsView.php # Custom field management
|
||||
│ │ ├── RecurringTicketsView.php # Recurring ticket management
|
||||
│ │ ├── TemplatesView.php # Template management
|
||||
│ │ ├── UserActivityView.php # User activity report
|
||||
│ │ └── WorkflowDesignerView.php # Workflow transition designer
|
||||
│ ├── CreateTicketView.php # Ticket creation with visibility
|
||||
│ ├── DashboardView.php # Dashboard with kanban + sidebar
|
||||
│ └── TicketView.php # Ticket view with visibility editing
|
||||
├── .env # Environment variables (GITIGNORED)
|
||||
├── README.md # This file
|
||||
└── index.php # Main router
|
||||
```
|
||||
|
||||
## Workflow States
|
||||
@@ -252,6 +324,118 @@ Open → Pending → In Progress → Closed
|
||||
All states can transition to Closed (with comment).
|
||||
Closed tickets can be reopened to Open or In Progress.
|
||||
|
||||
## Setup & Configuration
|
||||
|
||||
### 1. Environment Configuration
|
||||
|
||||
Copy the example file and edit with your values:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Required environment variables:
|
||||
```env
|
||||
DB_HOST=your_db_host
|
||||
DB_USER=your_db_user
|
||||
DB_PASS=your_password
|
||||
DB_NAME=ticketing_system
|
||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
APP_DOMAIN=your.domain.example
|
||||
TIMEZONE=America/New_York
|
||||
```
|
||||
|
||||
**Note**: `APP_DOMAIN` is required for Discord webhook ticket links to work correctly. Without it, links will default to localhost.
|
||||
|
||||
### 2. Cron Jobs
|
||||
|
||||
Add to crontab for recurring tickets:
|
||||
```bash
|
||||
# Run every hour to create scheduled recurring tickets
|
||||
0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php
|
||||
```
|
||||
|
||||
### 3. File Uploads
|
||||
|
||||
Ensure the `uploads/` directory exists and is writable:
|
||||
```bash
|
||||
mkdir -p /path/to/tinkertickets/uploads
|
||||
chown www-data:www-data /path/to/tinkertickets/uploads
|
||||
chmod 755 /path/to/tinkertickets/uploads
|
||||
```
|
||||
|
||||
### 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 `admin` group in LLDAP.
|
||||
|
||||
## Developer Notes
|
||||
|
||||
Key conventions and gotchas for working with this codebase:
|
||||
|
||||
1. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
|
||||
2. **API auth check**: Verify `$_SESSION['user']['user_id']` exists
|
||||
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
|
||||
4. **Config path**: `config/config.php` (not `config/db.php`)
|
||||
5. **Comments table**: `ticket_comments` (not `comments`)
|
||||
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header
|
||||
7. **Cache busting**: Use `?v=YYYYMMDD` query params on JS/CSS files
|
||||
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
|
||||
9. **User groups**: Stored in `users.groups` as comma-separated values
|
||||
10. **API routing**: All API endpoints must be registered in `index.php` router
|
||||
11. **Session in APIs**: `RateLimitMiddleware` starts the session — do not call `session_start()` again
|
||||
12. **Database collation**: Use `utf8mb4_general_ci` (not `unicode_ci`) for new tables
|
||||
13. **Discord URLs**: Set `APP_DOMAIN` in `.env` for correct ticket URLs in Discord notifications
|
||||
14. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
|
||||
15. **CSP nonces**: All inline `<script>` tags require `nonce="<?php echo $nonce; ?>"`
|
||||
16. **Visibility validation**: Internal visibility requires at least one group — validated server-side
|
||||
17. **Rate limiting**: Both session-based AND IP-based limits are enforced
|
||||
18. **Relative timestamps**: Dashboard dates use `lt.time.ago()` and refresh every 60s; full date is always in the `title` attribute for hover
|
||||
19. **Boot sequence**: Shows ASCII banner then boot messages on first page visit per session (`sessionStorage.getItem('booted')`); removed on subsequent loads
|
||||
20. **No raw `fetch()`**: All AJAX calls use `lt.api.get/post/put/delete()` — never use raw `fetch()`. The wrapper auto-adds `Content-Type: application/json` and `X-CSRF-Token`, auto-parses JSON, and throws on non-2xx.
|
||||
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
|
||||
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
|
||||
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
|
||||
24. **CSS utility classes** (dashboard.css): Use `.text-green`, `.text-amber`, `.text-cyan`, `.text-danger`, `.text-open`, `.text-closed`, `.text-muted`, `.text-muted-green`, `.text-sm`, `.text-center`, `.nowrap`, `.mono`, `.fw-bold`, `.mb-1` for typography. Use `.admin-container` (1200px) or `.admin-container-wide` (1400px) for admin page max-widths. Use `.admin-header-row` for title+button header rows. Use `.admin-form-row`, `.admin-form-field`, `.admin-label`, `.admin-input` for filter forms. Use `.admin-form-actions` for filter form button groups. Use `.table-wrapper` + `td.empty-state` for tables. Use `.setting-grid-2` and `.setting-grid-3` for modal form grids. Use `.lt-modal-sm` or `.lt-modal-lg` for modal sizing.
|
||||
25. **CSS utility classes** (ticket.css): Use `.form-hint` (green helper text), `.form-hint-warning` (amber helper text), `.visibility-groups-list` (checkbox row), `.group-checkbox-label` (checkbox label) for ticket forms.
|
||||
|
||||
## File Reference
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.php` | Main router for all routes |
|
||||
| `config/config.php` | Config loader + .env parsing |
|
||||
| `api/update_ticket.php` | Ticket updates with workflow + visibility validation |
|
||||
| `api/download_attachment.php` | File downloads with visibility check |
|
||||
| `api/bulk_operation.php` | Bulk operations with visibility filtering |
|
||||
| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation |
|
||||
| `models/ApiKeyModel.php` | API key generation and validation |
|
||||
| `middleware/AuthMiddleware.php` | Authelia header parsing + session setup |
|
||||
| `middleware/CsrfMiddleware.php` | CSRF token generation and validation |
|
||||
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with per-request nonce generation |
|
||||
| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting |
|
||||
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions |
|
||||
| `assets/js/ticket.js` | Ticket UI, visibility editing |
|
||||
| `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) |
|
||||
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar |
|
||||
|
||||
## Security Implementations
|
||||
|
||||
| Feature | Implementation |
|
||||
|---------|---------------|
|
||||
| SQL Injection | All queries use prepared statements with parameter binding |
|
||||
| XSS Prevention | HTML escaped in markdown parser; CSP with per-request nonces |
|
||||
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`) |
|
||||
| Session Security | Fixation prevention, secure cookies, session timeout |
|
||||
| Rate Limiting | Session-based + IP-based (file storage) |
|
||||
| File Security | Path traversal prevention, MIME type validation |
|
||||
| Visibility | Enforced on ticket views, downloads, and bulk operations |
|
||||
|
||||
## License
|
||||
|
||||
Internal use only - LotusGuild Infrastructure
|
||||
|
||||
@@ -30,7 +30,9 @@ try {
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
@@ -60,7 +62,14 @@ try {
|
||||
throw new Exception("Invalid JSON data received");
|
||||
}
|
||||
|
||||
$ticketId = $data['ticket_id'];
|
||||
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0;
|
||||
if ($ticketId <= 0) {
|
||||
http_response_code(400);
|
||||
ob_end_clean();
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Initialize models
|
||||
$commentModel = new CommentModel($conn);
|
||||
|
||||
@@ -6,10 +6,17 @@ require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
|
||||
// Get request data
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$ticketId = $data['ticket_id'] ?? null;
|
||||
if (!is_array($data)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : null;
|
||||
$assignedTo = $data['assigned_to'] ?? null;
|
||||
|
||||
if (!$ticketId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
|
||||
exit;
|
||||
}
|
||||
@@ -18,6 +25,21 @@ $ticketModel = new TicketModel($conn);
|
||||
$auditLogModel = new AuditLogModel($conn);
|
||||
$userModel = new UserModel($conn);
|
||||
|
||||
// Verify ticket exists
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$ticket) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Authorization: only admins or the ticket creator/assignee can reassign
|
||||
if (!$isAdmin && $ticket['created_by'] !== $userId && $ticket['assigned_to'] !== $userId) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($assignedTo === null || $assignedTo === '') {
|
||||
// Unassign ticket
|
||||
$success = $ticketModel->unassignTicket($ticketId, $userId);
|
||||
@@ -40,4 +62,9 @@ if ($assignedTo === null || $assignedTo === '') {
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => $success]);
|
||||
if (!$success) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to update ticket assignment']);
|
||||
} else {
|
||||
echo json_encode(['success' => true]);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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;
|
||||
}
|
||||
@@ -32,6 +33,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// Check admin status - bulk operations are admin-only
|
||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||
if (!$isAdmin) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ try {
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
@@ -50,8 +52,14 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$sourceTicketId = $data['ticket_id'];
|
||||
$sourceTicketId = (int)$data['ticket_id'];
|
||||
if ($sourceTicketId <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
|
||||
exit;
|
||||
}
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||
|
||||
// Get database connection
|
||||
$conn = Database::getConnection();
|
||||
@@ -66,6 +74,15 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Authorization: non-admins cannot clone internal tickets unless they created/are assigned
|
||||
if (!$isAdmin && ($sourceTicket['visibility'] ?? 'public') === 'internal') {
|
||||
if ($sourceTicket['created_by'] != $userId && $sourceTicket['assigned_to'] != $userId) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare cloned ticket data
|
||||
$clonedTicketData = [
|
||||
'title' => '[CLONE] ' . $sourceTicket['title'],
|
||||
|
||||
@@ -58,7 +58,7 @@ if (!$attachmentId || !is_numeric($attachmentId)) {
|
||||
$attachmentId = (int)$attachmentId;
|
||||
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel();
|
||||
$attachmentModel = new AttachmentModel(Database::getConnection());
|
||||
|
||||
// Get attachment details
|
||||
$attachment = $attachmentModel->getAttachment($attachmentId);
|
||||
|
||||
@@ -21,8 +21,19 @@ try {
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Only allow POST or DELETE — reject GET to prevent CSRF bypass
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
if ($method !== 'POST' && $method !== 'DELETE') {
|
||||
http_response_code(405);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
@@ -48,9 +59,9 @@ try {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!$data || !isset($data['comment_id'])) {
|
||||
// Try query params
|
||||
if (isset($_GET['comment_id'])) {
|
||||
$data = ['comment_id' => $_GET['comment_id']];
|
||||
// Also check POST params (no GET fallback — prevents CSRF bypass via URL)
|
||||
if (isset($_POST['comment_id'])) {
|
||||
$data = ['comment_id' => $_POST['comment_id']];
|
||||
} else {
|
||||
throw new Exception("Missing required field: comment_id");
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ if (!$attachmentId || !is_numeric($attachmentId)) {
|
||||
$attachmentId = (int)$attachmentId;
|
||||
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel();
|
||||
$attachmentModel = new AttachmentModel(Database::getConnection());
|
||||
|
||||
// Get attachment details
|
||||
$attachment = $attachmentModel->getAttachment($attachmentId);
|
||||
|
||||
@@ -22,7 +22,9 @@ try {
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ try {
|
||||
require_once $workflowModelPath;
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
@@ -77,6 +79,17 @@ try {
|
||||
];
|
||||
}
|
||||
|
||||
// Authorization: admins can edit any ticket; others only their own or assigned
|
||||
if (!$this->isAdmin
|
||||
&& $currentTicket['created_by'] != $this->userId
|
||||
&& $currentTicket['assigned_to'] != $this->userId
|
||||
) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Permission denied'
|
||||
];
|
||||
}
|
||||
|
||||
// Merge current data with updates, keeping existing values for missing fields
|
||||
$updateData = [
|
||||
'ticket_id' => $id,
|
||||
|
||||
@@ -46,7 +46,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
}
|
||||
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel();
|
||||
$attachmentModel = new AttachmentModel(Database::getConnection());
|
||||
$attachments = $attachmentModel->getAttachments($ticketId);
|
||||
|
||||
// Add formatted file size and icon to each attachment
|
||||
@@ -155,7 +155,7 @@ if (empty($originalFilename)) {
|
||||
|
||||
// Save to database
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel();
|
||||
$attachmentModel = new AttachmentModel($conn);
|
||||
$attachmentId = $attachmentModel->addAttachment(
|
||||
$ticketId,
|
||||
$uniqueFilename,
|
||||
|
||||
1701
assets/css/base.css
Normal file
1701
assets/css/base.css
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -262,12 +262,11 @@
|
||||
color: var(--terminal-green);
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.metadata-select:hover {
|
||||
border-color: var(--terminal-amber);
|
||||
box-shadow: var(--glow-amber);
|
||||
}
|
||||
|
||||
.metadata-select:focus {
|
||||
@@ -346,24 +345,28 @@ textarea[data-field="description"]:not(:disabled)::after {
|
||||
color: var(--terminal-amber);
|
||||
border-color: var(--terminal-amber);
|
||||
background: rgba(255, 176, 0, 0.1);
|
||||
box-shadow: 0 0 6px rgba(255, 176, 0, 0.4);
|
||||
animation: pulse-warning 2s ease-in-out infinite;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.ticket-age.age-critical {
|
||||
color: var(--priority-1);
|
||||
border-color: var(--priority-1);
|
||||
background: rgba(255, 77, 77, 0.15);
|
||||
box-shadow: 0 0 8px rgba(255, 77, 77, 0.5);
|
||||
animation: pulse-critical 1s ease-in-out infinite;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
@keyframes pulse-warning {
|
||||
0%, 100% { box-shadow: 0 0 5px rgba(255, 176, 0, 0.3); }
|
||||
50% { box-shadow: 0 0 15px rgba(255, 176, 0, 0.6); }
|
||||
0%, 100% { opacity: 0.75; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes pulse-critical {
|
||||
0%, 100% { box-shadow: 0 0 5px rgba(255, 77, 77, 0.3); }
|
||||
50% { box-shadow: 0 0 20px rgba(255, 77, 77, 0.8); }
|
||||
0%, 100% { opacity: 0.7; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Tab transition animations */
|
||||
@@ -463,6 +466,50 @@ textarea[data-field="description"]:not(:disabled)::after {
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
/* Helper text below form fields */
|
||||
.form-hint {
|
||||
color: var(--terminal-green);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.form-hint-warning {
|
||||
color: var(--terminal-amber);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Visibility group checkbox row */
|
||||
.visibility-groups-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.group-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Duplicate warning box and visibility groups (JS-toggled, need margin when visible) */
|
||||
#duplicateWarning {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
#visibilityGroupsContainer {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Duplicate found heading */
|
||||
.duplicate-heading {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-group {
|
||||
margin-bottom: 30px;
|
||||
padding: 15px;
|
||||
@@ -508,7 +555,7 @@ textarea[data-field="description"]:not(:disabled)::after {
|
||||
border-radius: 0;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.3s ease;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
input.editable {
|
||||
@@ -537,6 +584,8 @@ textarea.editable {
|
||||
background: var(--bg-secondary);
|
||||
cursor: default;
|
||||
border-color: transparent;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
@@ -548,7 +597,7 @@ textarea.editable {
|
||||
font-weight: 500;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.3s ease;
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
@@ -564,8 +613,6 @@ textarea.editable {
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Comments Section - TERMINAL STYLE */
|
||||
@@ -629,7 +676,7 @@ textarea.editable {
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
box-shadow: none;
|
||||
transition: all 0.3s ease;
|
||||
transition: border-color 0.2s ease;
|
||||
animation: comment-appear 0.4s ease-out;
|
||||
}
|
||||
|
||||
@@ -646,8 +693,6 @@ textarea.editable {
|
||||
|
||||
.comment:hover {
|
||||
border-color: var(--terminal-amber);
|
||||
background: linear-gradient(135deg, var(--bg-primary) 0%, rgba(255, 176, 0, 0.03) 100%);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 65, 0.1);
|
||||
}
|
||||
|
||||
.comment:hover::before,
|
||||
@@ -764,13 +809,16 @@ textarea.editable {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.comment-action-btn:hover {
|
||||
.comment-action-btn:hover,
|
||||
.comment-action-btn:focus-visible {
|
||||
background: rgba(0, 255, 65, 0.1);
|
||||
outline: 2px solid var(--terminal-amber);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.comment-action-btn.edit-btn:hover {
|
||||
@@ -942,6 +990,11 @@ textarea.editable {
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadein { animation: fadeIn 0.3s ease; }
|
||||
.is-hidden { display: none !important; }
|
||||
.animate-fadeout { animation: fadeIn 0.2s ease reverse; }
|
||||
.comment--deleting { opacity: 0; transform: translateX(-20px); transition: opacity 0.3s, transform 0.3s; }
|
||||
|
||||
.reply-form-container .reply-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1059,7 +1112,7 @@ textarea.editable {
|
||||
font-size: 1em;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--terminal-green);
|
||||
transition: all 0.3s ease;
|
||||
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
position: relative;
|
||||
margin-right: -2px;
|
||||
}
|
||||
@@ -1074,9 +1127,12 @@ textarea.editable {
|
||||
color: var(--terminal-green);
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
.tab-btn:hover,
|
||||
.tab-btn:focus-visible {
|
||||
background: rgba(0, 255, 65, 0.05);
|
||||
color: var(--terminal-amber);
|
||||
outline: 2px solid var(--terminal-amber);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
@@ -1159,7 +1215,7 @@ textarea.editable {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--bg-secondary);
|
||||
transition: .4s;
|
||||
transition: background-color 0.4s ease;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
@@ -1169,8 +1225,8 @@ textarea.editable {
|
||||
width: 16px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
background-color: var(--bg-primary);
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.slider.round {
|
||||
@@ -1328,24 +1384,24 @@ input:checked + .slider:before {
|
||||
}
|
||||
|
||||
body.dark-mode .timeline-content {
|
||||
--card-bg: #2d3748;
|
||||
--border-color: #444;
|
||||
--text-muted: #a0aec0;
|
||||
--text-secondary: #cbd5e0;
|
||||
background: #2d3748;
|
||||
color: #f7fafc;
|
||||
--card-bg: var(--bg-tertiary);
|
||||
--border-color: var(--border-color);
|
||||
--text-muted: var(--text-muted);
|
||||
--text-secondary: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
body.dark-mode .timeline-header strong {
|
||||
color: #f7fafc;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
body.dark-mode .timeline-action {
|
||||
color: #a0aec0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
body.dark-mode .timeline-date {
|
||||
color: #718096;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* Status select dropdown */
|
||||
.status-select {
|
||||
@@ -1357,38 +1413,38 @@ body.dark-mode .timeline-date {
|
||||
letter-spacing: 0.5px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: opacity 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.status-select:hover {
|
||||
opacity: 0.9;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(0, 255, 65, 0.4);
|
||||
}
|
||||
|
||||
.status-select:focus {
|
||||
outline: none;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
border-color: var(--terminal-amber);
|
||||
}
|
||||
|
||||
/* Status colors for dropdown */
|
||||
.status-select.status-open {
|
||||
background-color: var(--status-open) !important;
|
||||
color: white !important;
|
||||
color: var(--bg-primary) !important;
|
||||
}
|
||||
|
||||
.status-select.status-in-progress {
|
||||
background-color: var(--status-in-progress) !important;
|
||||
color: #212529 !important;
|
||||
color: var(--bg-primary) !important;
|
||||
}
|
||||
|
||||
.status-select.status-closed {
|
||||
background-color: var(--status-closed) !important;
|
||||
color: white !important;
|
||||
color: var(--bg-primary) !important;
|
||||
}
|
||||
|
||||
.status-select.status-resolved {
|
||||
background-color: #28a745 !important;
|
||||
color: white !important;
|
||||
background-color: var(--status-open) !important;
|
||||
color: var(--bg-primary) !important;
|
||||
}
|
||||
|
||||
/* Dropdown options inherit colors */
|
||||
@@ -1399,66 +1455,56 @@ body.dark-mode .timeline-date {
|
||||
}
|
||||
|
||||
body.dark-mode .status-select option {
|
||||
background-color: #2d3748;
|
||||
color: #f7fafc;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Dark mode for Activity tab and general improvements */
|
||||
body.dark-mode .tab-content {
|
||||
color: var(--text-primary, #f7fafc);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
body.dark-mode #activity-tab {
|
||||
background: var(--bg-secondary, #1a202c);
|
||||
color: var(--text-primary, #f7fafc);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
body.dark-mode #activity-tab p {
|
||||
color: var(--text-primary, #f7fafc);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Comprehensive Dark Mode Fix - Ensure no white on white */
|
||||
body.dark-mode {
|
||||
--bg-primary: #1a202c;
|
||||
--bg-secondary: #2d3748;
|
||||
--bg-tertiary: #4a5568;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #cbd5e0;
|
||||
--text-muted: #a0aec0;
|
||||
--border-color: #4a5568;
|
||||
--card-bg: #2d3748;
|
||||
}
|
||||
/* Comprehensive Dark Mode Fix - terminal CSS variables apply throughout */
|
||||
|
||||
/* Ensure ticket container has dark background */
|
||||
body.dark-mode .ticket-container {
|
||||
background: #1a202c !important;
|
||||
color: #e2e8f0 !important;
|
||||
background: var(--bg-secondary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Ensure all ticket details sections are dark */
|
||||
body.dark-mode .ticket-details {
|
||||
background: #1a202c !important;
|
||||
color: #e2e8f0 !important;
|
||||
background: var(--bg-secondary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Ensure detail groups are dark */
|
||||
body.dark-mode .detail-group {
|
||||
background: transparent !important;
|
||||
color: #e2e8f0 !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Ensure labels are visible */
|
||||
body.dark-mode .detail-group label,
|
||||
body.dark-mode label {
|
||||
color: #cbd5e0 !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
/* Fix textarea and input fields */
|
||||
body.dark-mode textarea,
|
||||
body.dark-mode input[type="text"] {
|
||||
background: #2d3748 !important;
|
||||
color: #e2e8f0 !important;
|
||||
border-color: #4a5568 !important;
|
||||
background: var(--bg-tertiary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
/* Ensure timeline event backgrounds are dark */
|
||||
@@ -1468,30 +1514,38 @@ body.dark-mode .timeline-event {
|
||||
|
||||
/* Fix any remaining white text issues */
|
||||
body.dark-mode .timeline-details {
|
||||
color: #cbd5e0 !important;
|
||||
color: var(--text-secondary) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Fix comment sections */
|
||||
body.dark-mode .comment {
|
||||
background: #2d3748 !important;
|
||||
color: #e2e8f0 !important;
|
||||
background: var(--bg-tertiary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
body.dark-mode .comment-text {
|
||||
color: #e2e8f0 !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
body.dark-mode .comment-header {
|
||||
color: #cbd5e0 !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
/* Fix any form elements */
|
||||
body.dark-mode select,
|
||||
body.dark-mode .editable {
|
||||
background: #2d3748 !important;
|
||||
color: #e2e8f0 !important;
|
||||
border-color: #4a5568 !important;
|
||||
background: var(--bg-tertiary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
/* ===== RELATIVE TIMESTAMP CELLS ===== */
|
||||
|
||||
span.ts-cell {
|
||||
cursor: help;
|
||||
border-bottom: 1px dotted var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE DESIGN - TERMINAL EDITION ===== */
|
||||
@@ -1608,7 +1662,7 @@ body.dark-mode .editable {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@@ -1695,7 +1749,7 @@ body.dark-mode .editable {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--terminal-green);
|
||||
background: var(--bg-primary);
|
||||
transition: all 0.2s ease;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.attachment-item:hover {
|
||||
@@ -1756,7 +1810,7 @@ body.dark-mode .editable {
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--priority-1);
|
||||
color: white;
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Mobile responsiveness for attachments */
|
||||
@@ -1786,12 +1840,11 @@ body.dark-mode .editable {
|
||||
border-radius: 0;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.mention:hover {
|
||||
background: rgba(0, 255, 255, 0.2);
|
||||
text-shadow: 0 0 5px var(--terminal-cyan);
|
||||
}
|
||||
|
||||
.mention::before {
|
||||
@@ -1821,7 +1874,7 @@ body.dark-mode .editable {
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--terminal-green);
|
||||
transition: all 0.2s ease;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -1862,7 +1915,7 @@ body.dark-mode .editable {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
@@ -1915,7 +1968,7 @@ body.dark-mode .editable {
|
||||
color: var(--terminal-green);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-mono);
|
||||
transition: all 0.2s ease;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.export-dropdown-content a:hover {
|
||||
@@ -2137,6 +2190,38 @@ body.dark-mode .editable {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Dependency list items */
|
||||
.dependency-group h4 {
|
||||
color: var(--terminal-amber);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.dependency-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px dashed var(--terminal-green-dim);
|
||||
}
|
||||
|
||||
.dependency-item a {
|
||||
color: var(--terminal-green);
|
||||
}
|
||||
|
||||
.dependency-item .dependency-title {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.dependency-item .status-badge {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.dependency-item .btn-small {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Upload progress */
|
||||
.upload-progress {
|
||||
margin-top: 1rem;
|
||||
@@ -2645,7 +2730,7 @@ body.dark-mode .editable {
|
||||
min-height: 44px;
|
||||
padding: 0.5rem;
|
||||
background: rgba(0, 255, 65, 0.05);
|
||||
border-radius: 4px;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
function openAdvancedSearch() {
|
||||
const modal = document.getElementById('advancedSearchModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
lt.modal.open('advancedSearchModal');
|
||||
loadUsersForSearch();
|
||||
populateCurrentFilters();
|
||||
loadSavedFilters();
|
||||
@@ -17,28 +16,13 @@ function openAdvancedSearch() {
|
||||
|
||||
// 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();
|
||||
}
|
||||
lt.modal.close('advancedSearchModal');
|
||||
}
|
||||
|
||||
// Load users for dropdown
|
||||
async function loadUsersForSearch() {
|
||||
try {
|
||||
const response = await fetch('/api/get_users.php', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await lt.api.get('/api/get_users.php');
|
||||
|
||||
if (data.success && data.users) {
|
||||
const createdBySelect = document.getElementById('adv-created-by');
|
||||
@@ -68,7 +52,7 @@ async function loadUsersForSearch() {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
lt.toast.error('Error loading users');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,37 +140,21 @@ async function saveCurrentFilter() {
|
||||
'My Filter',
|
||||
async (filterName) => {
|
||||
if (!filterName || filterName.trim() === '') {
|
||||
toast.warning('Filter name cannot be empty', 2000);
|
||||
lt.toast.warning('Filter name cannot be empty', 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
const filterCriteria = getCurrentFilterCriteria();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/saved_filters.php', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filter_name: filterName.trim(),
|
||||
filter_criteria: filterCriteria
|
||||
})
|
||||
await lt.api.post('/api/saved_filters.php', {
|
||||
filter_name: filterName.trim(),
|
||||
filter_criteria: filterCriteria
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`Filter "${filterName}" saved successfully!`, 3000);
|
||||
loadSavedFilters();
|
||||
} else {
|
||||
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'), 4000);
|
||||
}
|
||||
lt.toast.success(`Filter "${filterName}" saved successfully!`, 3000);
|
||||
loadSavedFilters();
|
||||
} catch (error) {
|
||||
console.error('Error saving filter:', error);
|
||||
toast.error('Error saving filter', 4000);
|
||||
lt.toast.error('Error saving filter: ' + (error.message || 'Unknown error'), 4000);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -233,16 +201,12 @@ function getCurrentFilterCriteria() {
|
||||
// Load saved filters
|
||||
async function loadSavedFilters() {
|
||||
try {
|
||||
const response = await fetch('/api/saved_filters.php', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const data = await lt.api.get('/api/saved_filters.php');
|
||||
if (data.success && data.filters) {
|
||||
populateSavedFiltersDropdown(data.filters);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading saved filters:', error);
|
||||
lt.toast.error('Error loading saved filters');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +241,7 @@ function loadSavedFilter() {
|
||||
const criteria = JSON.parse(selectedOption.dataset.criteria);
|
||||
applySavedFilterCriteria(criteria);
|
||||
} catch (error) {
|
||||
console.error('Error loading filter:', error);
|
||||
lt.toast.error('Error loading filter');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,9 +278,7 @@ async function deleteSavedFilter() {
|
||||
const selectedOption = dropdown.options[dropdown.selectedIndex];
|
||||
|
||||
if (!selectedOption || selectedOption.value === '') {
|
||||
if (typeof toast !== 'undefined') {
|
||||
toast.error('Please select a filter to delete');
|
||||
}
|
||||
lt.toast.error('Please select a filter to delete');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -329,45 +291,21 @@ async function deleteSavedFilter() {
|
||||
'error',
|
||||
async () => {
|
||||
try {
|
||||
const response = await fetch('/api/saved_filters.php', {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({ filter_id: filterId })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Filter deleted successfully', 3000);
|
||||
loadSavedFilters();
|
||||
resetAdvancedSearch();
|
||||
} else {
|
||||
toast.error('Failed to delete filter', 4000);
|
||||
}
|
||||
await lt.api.delete('/api/saved_filters.php', { filter_id: filterId });
|
||||
lt.toast.success('Filter deleted successfully', 3000);
|
||||
loadSavedFilters();
|
||||
resetAdvancedSearch();
|
||||
} catch (error) {
|
||||
console.error('Error deleting filter:', error);
|
||||
toast.error('Error deleting filter', 4000);
|
||||
lt.toast.error('Error deleting filter', 4000);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Keyboard shortcut (Ctrl+Shift+F)
|
||||
// Keyboard shortcut (Ctrl+Shift+F) — ESC is handled globally by lt.keys.initDefaults()
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,26 +60,13 @@ function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = tru
|
||||
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.className = addGlow ? 'ascii-banner ascii-banner--glow' : 'ascii-banner';
|
||||
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);
|
||||
|
||||
@@ -179,8 +166,7 @@ function animatedWelcome(containerSelector) {
|
||||
banner.addEventListener('bannerComplete', () => {
|
||||
const cursor = document.createElement('span');
|
||||
cursor.textContent = '█';
|
||||
cursor.style.animation = 'blink-caret 0.75s step-end infinite';
|
||||
cursor.style.marginLeft = '5px';
|
||||
cursor.className = 'ascii-banner-cursor';
|
||||
banner.appendChild(cursor);
|
||||
});
|
||||
}
|
||||
|
||||
793
assets/js/base.js
Normal file
793
assets/js/base.js
Normal file
@@ -0,0 +1,793 @@
|
||||
/**
|
||||
* LOTUSGUILD TERMINAL DESIGN SYSTEM v1.0 — base.js
|
||||
* Core JavaScript utilities shared across all LotusGuild applications
|
||||
*
|
||||
* Apps: Tinker Tickets (PHP), PULSE (Node.js), GANDALF (Flask)
|
||||
* Namespace: window.lt
|
||||
*
|
||||
* CONTENTS
|
||||
* 1. HTML Escape
|
||||
* 2. Toast Notifications
|
||||
* 3. Terminal Audio (beep)
|
||||
* 4. Modal Management
|
||||
* 5. Tab Management
|
||||
* 6. Boot Sequence Animation
|
||||
* 7. Keyboard Shortcuts
|
||||
* 8. Sidebar Collapse
|
||||
* 9. CSRF Token Helpers
|
||||
* 10. Fetch Helpers (JSON API wrapper)
|
||||
* 11. Time Formatting
|
||||
* 12. Bytes Formatting
|
||||
* 13. Table Keyboard Navigation
|
||||
* 14. Sortable Table Headers
|
||||
* 15. Stats Widget Filtering
|
||||
* 16. Auto-refresh Manager
|
||||
* 17. Initialisation
|
||||
*/
|
||||
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
1. HTML ESCAPE
|
||||
---------------------------------------------------------------- */
|
||||
/**
|
||||
* Escape a value for safe insertion into innerHTML.
|
||||
* Always prefer textContent/innerText when possible, but use this
|
||||
* when you must build HTML strings (e.g. template literals for lists).
|
||||
*
|
||||
* @param {*} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function escHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
2. TOAST NOTIFICATIONS
|
||||
----------------------------------------------------------------
|
||||
Usage:
|
||||
lt.toast.success('Ticket saved');
|
||||
lt.toast.error('Network error', 5000);
|
||||
lt.toast.warning('Rate limit approaching');
|
||||
lt.toast.info('Workflow started');
|
||||
---------------------------------------------------------------- */
|
||||
const _toastQueue = [];
|
||||
let _toastActive = false;
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {'success'|'error'|'warning'|'info'} type
|
||||
* @param {number} [duration=3500] ms before auto-dismiss
|
||||
*/
|
||||
function showToast(message, type, duration) {
|
||||
type = type || 'info';
|
||||
duration = duration || 3500;
|
||||
|
||||
if (_toastActive) {
|
||||
_toastQueue.push({ message, type, duration });
|
||||
return;
|
||||
}
|
||||
_displayToast(message, type, duration);
|
||||
}
|
||||
|
||||
function _displayToast(message, type, duration) {
|
||||
_toastActive = true;
|
||||
|
||||
let container = document.querySelector('.lt-toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'lt-toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const icons = { success: '✓', error: '✗', warning: '!', info: 'i' };
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'lt-toast lt-toast-' + type;
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
|
||||
const iconEl = document.createElement('span');
|
||||
iconEl.className = 'lt-toast-icon';
|
||||
iconEl.textContent = '[' + (icons[type] || 'i') + ']';
|
||||
|
||||
const msgEl = document.createElement('span');
|
||||
msgEl.className = 'lt-toast-msg';
|
||||
msgEl.textContent = message;
|
||||
|
||||
const closeEl = document.createElement('button');
|
||||
closeEl.className = 'lt-toast-close';
|
||||
closeEl.textContent = '✕';
|
||||
closeEl.setAttribute('aria-label', 'Dismiss');
|
||||
closeEl.addEventListener('click', () => _dismissToast(toast));
|
||||
|
||||
toast.appendChild(iconEl);
|
||||
toast.appendChild(msgEl);
|
||||
toast.appendChild(closeEl);
|
||||
container.appendChild(toast);
|
||||
|
||||
/* Auto-dismiss */
|
||||
const timer = setTimeout(() => _dismissToast(toast), duration);
|
||||
toast._lt_timer = timer;
|
||||
|
||||
/* Optional audio feedback */
|
||||
_beep(type);
|
||||
}
|
||||
|
||||
function _dismissToast(toast) {
|
||||
if (!toast || !toast.parentNode) return;
|
||||
clearTimeout(toast._lt_timer);
|
||||
toast.classList.add('lt-toast--hiding');
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) toast.parentNode.removeChild(toast);
|
||||
_toastActive = false;
|
||||
if (_toastQueue.length) {
|
||||
const next = _toastQueue.shift();
|
||||
_displayToast(next.message, next.type, next.duration);
|
||||
}
|
||||
}, 320);
|
||||
}
|
||||
|
||||
const toast = {
|
||||
success: (msg, dur) => showToast(msg, 'success', dur),
|
||||
error: (msg, dur) => showToast(msg, 'error', dur),
|
||||
warning: (msg, dur) => showToast(msg, 'warning', dur),
|
||||
info: (msg, dur) => showToast(msg, 'info', dur),
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
3. TERMINAL AUDIO
|
||||
----------------------------------------------------------------
|
||||
Usage: lt.beep('success' | 'error' | 'info')
|
||||
Silent-fails if Web Audio API is unavailable.
|
||||
---------------------------------------------------------------- */
|
||||
function _beep(type) {
|
||||
try {
|
||||
const ctx = new (global.AudioContext || global.webkitAudioContext)();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = type === 'success' ? 880
|
||||
: type === 'error' ? 220
|
||||
: 440;
|
||||
osc.type = 'sine';
|
||||
gain.gain.setValueAtTime(0.08, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.12);
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.12);
|
||||
} catch (_) { /* silently fail */ }
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
4. MODAL MANAGEMENT
|
||||
----------------------------------------------------------------
|
||||
Usage:
|
||||
lt.modal.open('my-modal-id');
|
||||
lt.modal.close('my-modal-id');
|
||||
lt.modal.closeAll();
|
||||
|
||||
HTML contract:
|
||||
<div id="my-modal-id" class="lt-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="myModalTitle">
|
||||
<div class="lt-modal">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="myModalTitle">Title</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body">…</div>
|
||||
<div class="lt-modal-footer">…</div>
|
||||
</div>
|
||||
</div>
|
||||
---------------------------------------------------------------- */
|
||||
function openModal(id) {
|
||||
const el = typeof id === 'string' ? document.getElementById(id) : id;
|
||||
if (!el) return;
|
||||
el.classList.add('show');
|
||||
el.setAttribute('aria-hidden', 'false');
|
||||
document.body.style.overflow = 'hidden';
|
||||
/* Focus first focusable element */
|
||||
const first = el.querySelector('button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (first) setTimeout(() => first.focus(), 50);
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
const el = typeof id === 'string' ? document.getElementById(id) : id;
|
||||
if (!el) return;
|
||||
el.classList.remove('show');
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function closeAllModals() {
|
||||
document.querySelectorAll('.lt-modal-overlay.show').forEach(closeModal);
|
||||
}
|
||||
|
||||
/* Delegated close handlers */
|
||||
document.addEventListener('click', function (e) {
|
||||
/* Click on overlay backdrop (outside .lt-modal) */
|
||||
if (e.target.classList.contains('lt-modal-overlay')) {
|
||||
closeModal(e.target);
|
||||
return;
|
||||
}
|
||||
/* [data-modal-close] button */
|
||||
const closeBtn = e.target.closest('[data-modal-close]');
|
||||
if (closeBtn) {
|
||||
const overlay = closeBtn.closest('.lt-modal-overlay');
|
||||
if (overlay) closeModal(overlay);
|
||||
}
|
||||
/* [data-modal-open="id"] trigger */
|
||||
const openBtn = e.target.closest('[data-modal-open]');
|
||||
if (openBtn) openModal(openBtn.dataset.modalOpen);
|
||||
});
|
||||
|
||||
const modal = { open: openModal, close: closeModal, closeAll: closeAllModals };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
5. TAB MANAGEMENT
|
||||
----------------------------------------------------------------
|
||||
Usage:
|
||||
lt.tabs.init(); // auto-wires all .lt-tab elements
|
||||
lt.tabs.switch('tab-panel-id');
|
||||
|
||||
HTML contract:
|
||||
<div class="lt-tabs">
|
||||
<button class="lt-tab active" data-tab="panel-one">One</button>
|
||||
<button class="lt-tab" data-tab="panel-two">Two</button>
|
||||
</div>
|
||||
<div id="panel-one" class="lt-tab-panel active">…</div>
|
||||
<div id="panel-two" class="lt-tab-panel">…</div>
|
||||
|
||||
Persistence: localStorage key 'lt_activeTab_<page>'
|
||||
---------------------------------------------------------------- */
|
||||
function switchTab(panelId) {
|
||||
document.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active'));
|
||||
|
||||
const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]');
|
||||
const panel = document.getElementById(panelId);
|
||||
if (btn) btn.classList.add('active');
|
||||
if (panel) panel.classList.add('active');
|
||||
|
||||
try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
|
||||
}
|
||||
|
||||
function initTabs() {
|
||||
/* Restore from localStorage */
|
||||
try {
|
||||
const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
|
||||
if (saved && document.getElementById(saved)) { switchTab(saved); return; }
|
||||
} catch (_) {}
|
||||
|
||||
/* Wire click handlers */
|
||||
document.querySelectorAll('.lt-tab[data-tab]').forEach(btn => {
|
||||
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||||
});
|
||||
}
|
||||
|
||||
const tabs = { init: initTabs, switch: switchTab };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
6. BOOT SEQUENCE ANIMATION
|
||||
----------------------------------------------------------------
|
||||
Usage:
|
||||
lt.boot.run('APP NAME'); // shows once per session
|
||||
lt.boot.run('APP NAME', true); // force show even if already seen
|
||||
|
||||
HTML contract (add to <body>, hidden by default):
|
||||
<div id="lt-boot" class="lt-boot-overlay" style="display:none">
|
||||
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||
</div>
|
||||
---------------------------------------------------------------- */
|
||||
function runBoot(appName, force) {
|
||||
const storageKey = 'lt_booted_' + (appName || 'app');
|
||||
if (!force && sessionStorage.getItem(storageKey)) return;
|
||||
|
||||
const overlay = document.getElementById('lt-boot');
|
||||
const pre = document.getElementById('lt-boot-text');
|
||||
if (!overlay || !pre) return;
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
|
||||
const name = (appName || 'TERMINAL').toUpperCase();
|
||||
const titleStr = name + ' v1.0';
|
||||
const innerWidth = 43;
|
||||
const leftPad = Math.max(0, Math.floor((innerWidth - titleStr.length) / 2));
|
||||
const rightPad = Math.max(0, innerWidth - titleStr.length - leftPad);
|
||||
const messages = [
|
||||
'╔═══════════════════════════════════════════╗',
|
||||
'║' + ' '.repeat(leftPad) + titleStr + ' '.repeat(rightPad) + '║',
|
||||
'║ BOOTING SYSTEM... ║',
|
||||
'╚═══════════════════════════════════════════╝',
|
||||
'',
|
||||
'[ OK ] Checking kernel modules...',
|
||||
'[ OK ] Mounting filesystem...',
|
||||
'[ OK ] Initializing database connection...',
|
||||
'[ OK ] Loading user session...',
|
||||
'[ OK ] Applying security headers...',
|
||||
'[ OK ] Rendering terminal interface...',
|
||||
'',
|
||||
'> SYSTEM READY ✓',
|
||||
'',
|
||||
];
|
||||
|
||||
let i = 0;
|
||||
pre.textContent = '';
|
||||
const interval = setInterval(() => {
|
||||
if (i < messages.length) {
|
||||
pre.textContent += messages[i] + '\n';
|
||||
i++;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
setTimeout(() => {
|
||||
overlay.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
overlay.style.display = 'none';
|
||||
overlay.classList.remove('fade-out');
|
||||
}, 520);
|
||||
}, 400);
|
||||
sessionStorage.setItem(storageKey, '1');
|
||||
}
|
||||
}, 80);
|
||||
}
|
||||
|
||||
const boot = { run: runBoot };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
7. KEYBOARD SHORTCUTS
|
||||
----------------------------------------------------------------
|
||||
Register handlers:
|
||||
lt.keys.on('ctrl+k', () => searchBox.focus());
|
||||
lt.keys.on('?', showHelpModal);
|
||||
lt.keys.on('Escape', lt.modal.closeAll);
|
||||
|
||||
Built-in defaults (activate with lt.keys.initDefaults()):
|
||||
ESC → close all modals
|
||||
? → show #lt-keys-help modal if present
|
||||
Ctrl/⌘+K → focus .lt-search-input
|
||||
---------------------------------------------------------------- */
|
||||
const _keyHandlers = {};
|
||||
|
||||
function normalizeKey(combo) {
|
||||
return combo
|
||||
.replace(/ctrl\+/i, 'ctrl+')
|
||||
.replace(/cmd\+/i, 'ctrl+') /* treat Cmd as Ctrl */
|
||||
.replace(/meta\+/i, 'ctrl+')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function registerKey(combo, handler) {
|
||||
_keyHandlers[normalizeKey(combo)] = handler;
|
||||
}
|
||||
|
||||
function unregisterKey(combo) {
|
||||
delete _keyHandlers[normalizeKey(combo)];
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
const inInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)
|
||||
|| e.target.isContentEditable;
|
||||
|
||||
/* Build the combo string */
|
||||
let combo = '';
|
||||
if (e.ctrlKey || e.metaKey) combo += 'ctrl+';
|
||||
if (e.altKey) combo += 'alt+';
|
||||
if (e.shiftKey) combo += 'shift+';
|
||||
combo += e.key.toLowerCase();
|
||||
|
||||
/* Always fire ESC, Ctrl combos regardless of input focus */
|
||||
const alwaysFire = e.key === 'Escape' || e.ctrlKey || e.metaKey;
|
||||
|
||||
if (inInput && !alwaysFire) return;
|
||||
|
||||
const handler = _keyHandlers[combo];
|
||||
if (handler) {
|
||||
e.preventDefault();
|
||||
handler(e);
|
||||
}
|
||||
});
|
||||
|
||||
function initDefaultKeys() {
|
||||
registerKey('Escape', closeAllModals);
|
||||
registerKey('?', () => {
|
||||
const helpModal = document.getElementById('lt-keys-help');
|
||||
if (helpModal) openModal(helpModal);
|
||||
});
|
||||
registerKey('ctrl+k', () => {
|
||||
const search = document.querySelector('.lt-search-input');
|
||||
if (search) { search.focus(); search.select(); }
|
||||
});
|
||||
}
|
||||
|
||||
const keys = {
|
||||
on: registerKey,
|
||||
off: unregisterKey,
|
||||
initDefaults: initDefaultKeys,
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
8. SIDEBAR COLLAPSE
|
||||
----------------------------------------------------------------
|
||||
Usage: lt.sidebar.init();
|
||||
|
||||
HTML contract:
|
||||
<aside class="lt-sidebar" id="lt-sidebar">
|
||||
<div class="lt-sidebar-header">
|
||||
Filters
|
||||
<button class="lt-sidebar-toggle" data-sidebar-toggle="lt-sidebar">◀</button>
|
||||
</div>
|
||||
<div class="lt-sidebar-body">…</div>
|
||||
</aside>
|
||||
---------------------------------------------------------------- */
|
||||
function initSidebar() {
|
||||
document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => {
|
||||
const sidebar = document.getElementById(btn.dataset.sidebarToggle);
|
||||
if (!sidebar) return;
|
||||
|
||||
/* Restore state */
|
||||
const collapsed = sessionStorage.getItem('lt_sidebar_' + btn.dataset.sidebarToggle) === '1';
|
||||
if (collapsed) {
|
||||
sidebar.classList.add('collapsed');
|
||||
btn.textContent = '▶';
|
||||
}
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
const isCollapsed = sidebar.classList.contains('collapsed');
|
||||
btn.textContent = isCollapsed ? '▶' : '◀';
|
||||
try { sessionStorage.setItem('lt_sidebar_' + btn.dataset.sidebarToggle, isCollapsed ? '1' : '0'); } catch (_) {}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const sidebar = { init: initSidebar };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
9. CSRF TOKEN HELPERS
|
||||
----------------------------------------------------------------
|
||||
PHP apps: window.CSRF_TOKEN is set by the view via:
|
||||
<script nonce="...">window.CSRF_TOKEN = '<?= CsrfMiddleware::getToken() ?>';</script>
|
||||
Node apps: set via: window.CSRF_TOKEN = '<%= csrfToken %>';
|
||||
Flask: use Flask-WTF meta tag or inject via template.
|
||||
|
||||
Usage:
|
||||
const headers = lt.csrf.headers();
|
||||
fetch('/api/foo', { method: 'POST', headers: lt.csrf.headers(), body: … });
|
||||
---------------------------------------------------------------- */
|
||||
function csrfHeaders() {
|
||||
const token = global.CSRF_TOKEN || '';
|
||||
return token ? { 'X-CSRF-Token': token } : {};
|
||||
}
|
||||
|
||||
const csrf = { headers: csrfHeaders };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
10. FETCH HELPERS
|
||||
----------------------------------------------------------------
|
||||
Usage:
|
||||
const data = await lt.api.get('/api/tickets');
|
||||
const res = await lt.api.post('/api/update_ticket.php', { id: 1, status: 'Closed' });
|
||||
const res = await lt.api.delete('/api/ticket_dependencies.php', { id: 5 });
|
||||
|
||||
All methods:
|
||||
- Automatically set Content-Type: application/json
|
||||
- Attach CSRF token header
|
||||
- Parse JSON response
|
||||
- On non-2xx: throw an Error with the server's error message
|
||||
---------------------------------------------------------------- */
|
||||
async function apiFetch(method, url, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: Object.assign(
|
||||
{ 'Content-Type': 'application/json' },
|
||||
csrfHeaders()
|
||||
),
|
||||
};
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, opts);
|
||||
} catch (networkErr) {
|
||||
throw new Error('Network error: ' + networkErr.message);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await resp.json();
|
||||
} catch (_) {
|
||||
data = { success: resp.ok };
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(data.error || data.message || 'HTTP ' + resp.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const api = {
|
||||
get: (url) => apiFetch('GET', url),
|
||||
post: (url, body) => apiFetch('POST', url, body),
|
||||
put: (url, body) => apiFetch('PUT', url, body),
|
||||
patch: (url, body) => apiFetch('PATCH', url, body),
|
||||
delete: (url, body) => apiFetch('DELETE', url, body),
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
11. TIME FORMATTING
|
||||
---------------------------------------------------------------- */
|
||||
/**
|
||||
* Returns a human-readable relative time string.
|
||||
* @param {string|number|Date} value ISO string, Unix ms, or Date
|
||||
* @returns {string} e.g. "5m ago", "2h ago", "3d ago"
|
||||
*/
|
||||
function timeAgo(value) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (isNaN(date)) return '—';
|
||||
const diff = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (diff < 60) return diff + 's ago';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds → "1h 23m 45s" style.
|
||||
* @param {number} secs
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatUptime(secs) {
|
||||
secs = Math.floor(secs);
|
||||
const d = Math.floor(secs / 86400);
|
||||
const h = Math.floor((secs % 86400) / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
const parts = [];
|
||||
if (d) parts.push(d + 'd');
|
||||
if (h) parts.push(h + 'h');
|
||||
if (m) parts.push(m + 'm');
|
||||
if (!d) parts.push(s + 's');
|
||||
return parts.join(' ') || '0s';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO datetime string for display.
|
||||
* Uses the timezone configured in window.APP_TIMEZONE (PHP apps)
|
||||
* or falls back to the browser locale.
|
||||
*/
|
||||
function formatDate(value) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (isNaN(date)) return '—';
|
||||
const tz = global.APP_TIMEZONE || undefined;
|
||||
try {
|
||||
return date.toLocaleString(undefined, {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
} catch (_) {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
const time = { ago: timeAgo, uptime: formatUptime, format: formatDate };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
12. BYTES FORMATTING
|
||||
---------------------------------------------------------------- */
|
||||
/**
|
||||
* @param {number} bytes
|
||||
* @returns {string} e.g. "1.23 GB"
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === null || bytes === undefined) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
|
||||
return bytes.toFixed(i === 0 ? 0 : 2) + ' ' + units[i];
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
13. TABLE KEYBOARD NAVIGATION (vim-style j/k)
|
||||
----------------------------------------------------------------
|
||||
Usage: lt.tableNav.init('my-table-id');
|
||||
|
||||
Keys registered:
|
||||
j or ArrowDown → move selection down
|
||||
k or ArrowUp → move selection up
|
||||
Enter → follow first <a> in selected row
|
||||
---------------------------------------------------------------- */
|
||||
function initTableNav(tableId) {
|
||||
const table = tableId ? document.getElementById(tableId) : document.querySelector('.lt-table');
|
||||
if (!table) return;
|
||||
|
||||
function rows() { return Array.from(table.querySelectorAll('tbody tr')); }
|
||||
function selected() { return table.querySelector('tbody tr.lt-row-selected'); }
|
||||
|
||||
function move(dir) {
|
||||
const all = rows();
|
||||
if (!all.length) return;
|
||||
const cur = selected();
|
||||
const idx = cur ? all.indexOf(cur) : -1;
|
||||
const next = dir === 'down'
|
||||
? all[idx < all.length - 1 ? idx + 1 : 0]
|
||||
: all[idx > 0 ? idx - 1 : all.length - 1];
|
||||
if (cur) cur.classList.remove('lt-row-selected');
|
||||
next.classList.add('lt-row-selected');
|
||||
next.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
keys.on('j', () => move('down'));
|
||||
keys.on('ArrowDown', () => move('down'));
|
||||
keys.on('k', () => move('up'));
|
||||
keys.on('ArrowUp', () => move('up'));
|
||||
keys.on('Enter', () => {
|
||||
const row = selected();
|
||||
if (!row) return;
|
||||
const link = row.querySelector('a[href]');
|
||||
if (link) global.location.href = link.href;
|
||||
});
|
||||
}
|
||||
|
||||
const tableNav = { init: initTableNav };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
14. SORTABLE TABLE HEADERS
|
||||
----------------------------------------------------------------
|
||||
Usage: lt.sortTable.init('my-table-id');
|
||||
|
||||
Markup: add data-sort-key="field" to <th> elements.
|
||||
Sorts rows client-side by the text content of the matching column.
|
||||
---------------------------------------------------------------- */
|
||||
function initSortTable(tableId) {
|
||||
const table = document.getElementById(tableId);
|
||||
if (!table) return;
|
||||
|
||||
const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
|
||||
ths.forEach((th, colIdx) => {
|
||||
let dir = 'asc';
|
||||
|
||||
th.addEventListener('click', () => {
|
||||
/* Reset all headers */
|
||||
ths.forEach(h => h.removeAttribute('data-sort'));
|
||||
th.setAttribute('data-sort', dir);
|
||||
|
||||
const tbody = table.querySelector('tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
rows.sort((a, b) => {
|
||||
const aText = (a.cells[colIdx] || {}).textContent || '';
|
||||
const bText = (b.cells[colIdx] || {}).textContent || '';
|
||||
const n = !isNaN(parseFloat(aText)) && !isNaN(parseFloat(bText));
|
||||
const cmp = n
|
||||
? parseFloat(aText) - parseFloat(bText)
|
||||
: aText.localeCompare(bText);
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
rows.forEach(r => tbody.appendChild(r));
|
||||
dir = dir === 'asc' ? 'desc' : 'asc';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const sortTable = { init: initSortTable };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
15. STATS WIDGET FILTERING
|
||||
----------------------------------------------------------------
|
||||
Usage: lt.statsFilter.init();
|
||||
|
||||
HTML contract:
|
||||
<div class="lt-stat-card" data-filter-key="status" data-filter-val="Open">…</div>
|
||||
<!-- clicking the card adds ?filter=status:Open to the URL and
|
||||
calls the optional window.lt_onStatFilter(key, val) hook -->
|
||||
---------------------------------------------------------------- */
|
||||
function initStatsFilter() {
|
||||
document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const key = card.dataset.filterKey;
|
||||
const val = card.dataset.filterVal;
|
||||
|
||||
/* Toggle active state */
|
||||
const wasActive = card.classList.contains('active');
|
||||
document.querySelectorAll('.lt-stat-card').forEach(c => c.classList.remove('active'));
|
||||
if (!wasActive) card.classList.add('active');
|
||||
|
||||
/* Call app-specific filter hook if defined */
|
||||
if (typeof global.lt_onStatFilter === 'function') {
|
||||
global.lt_onStatFilter(wasActive ? null : key, wasActive ? null : val);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const statsFilter = { init: initStatsFilter };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
16. AUTO-REFRESH MANAGER
|
||||
----------------------------------------------------------------
|
||||
Usage:
|
||||
lt.autoRefresh.start(refreshFn, 30000); // every 30 s
|
||||
lt.autoRefresh.stop();
|
||||
lt.autoRefresh.now(); // trigger immediately + restart timer
|
||||
---------------------------------------------------------------- */
|
||||
let _arTimer = null;
|
||||
let _arFn = null;
|
||||
let _arInterval = 30000;
|
||||
|
||||
function arStart(fn, intervalMs) {
|
||||
arStop();
|
||||
_arFn = fn;
|
||||
_arInterval = intervalMs || 30000;
|
||||
_arTimer = setInterval(_arFn, _arInterval);
|
||||
}
|
||||
|
||||
function arStop() {
|
||||
if (_arTimer) { clearInterval(_arTimer); _arTimer = null; }
|
||||
}
|
||||
|
||||
function arNow() {
|
||||
arStop();
|
||||
if (_arFn) {
|
||||
_arFn();
|
||||
_arTimer = setInterval(_arFn, _arInterval);
|
||||
}
|
||||
}
|
||||
|
||||
const autoRefresh = { start: arStart, stop: arStop, now: arNow };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
17. INITIALISATION
|
||||
----------------------------------------------------------------
|
||||
Called automatically on DOMContentLoaded.
|
||||
Each sub-system can also be initialised manually after the DOM
|
||||
has been updated with AJAX content.
|
||||
---------------------------------------------------------------- */
|
||||
function init() {
|
||||
initTabs();
|
||||
initSidebar();
|
||||
initDefaultKeys();
|
||||
initStatsFilter();
|
||||
|
||||
/* Boot sequence: runs if #lt-boot element is present */
|
||||
const bootEl = document.getElementById('lt-boot');
|
||||
if (bootEl) {
|
||||
const appName = bootEl.dataset.appName || document.title;
|
||||
runBoot(appName);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Public API
|
||||
---------------------------------------------------------------- */
|
||||
global.lt = {
|
||||
escHtml,
|
||||
toast,
|
||||
beep: _beep,
|
||||
modal,
|
||||
tabs,
|
||||
boot,
|
||||
keys,
|
||||
sidebar,
|
||||
csrf,
|
||||
api,
|
||||
time,
|
||||
bytes: { format: formatBytes },
|
||||
tableNav,
|
||||
sortTable,
|
||||
statsFilter,
|
||||
autoRefresh,
|
||||
};
|
||||
|
||||
}(window));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,173 +1,9 @@
|
||||
/**
|
||||
* Keyboard shortcuts for power users
|
||||
* Keyboard shortcuts for power users.
|
||||
* App-specific shortcuts registered via lt.keys.on() from web_template/base.js.
|
||||
* ESC, Ctrl+K, and ? are handled by lt.keys.initDefaults().
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('keydown', function(e) {
|
||||
// ESC: Close modals, cancel edit mode, blur inputs
|
||||
if (e.key === 'Escape') {
|
||||
// Close any open modals first
|
||||
const openModals = document.querySelectorAll('.modal-overlay');
|
||||
let closedModal = false;
|
||||
openModals.forEach(modal => {
|
||||
if (modal.style.display !== 'none' && modal.offsetParent !== null) {
|
||||
modal.remove();
|
||||
document.body.classList.remove('modal-open');
|
||||
closedModal = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Close settings modal if open
|
||||
const settingsModal = document.getElementById('settingsModal');
|
||||
if (settingsModal && settingsModal.style.display !== 'none') {
|
||||
settingsModal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
closedModal = true;
|
||||
}
|
||||
|
||||
// Close advanced search modal if open
|
||||
const searchModal = document.getElementById('advancedSearchModal');
|
||||
if (searchModal && searchModal.style.display !== 'none') {
|
||||
searchModal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
closedModal = true;
|
||||
}
|
||||
|
||||
// If we closed a modal, stop here
|
||||
if (closedModal) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Blur any focused input
|
||||
if (e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.isContentEditable) {
|
||||
e.target.blur();
|
||||
}
|
||||
|
||||
// Cancel edit mode on ticket pages
|
||||
const editButton = document.getElementById('editButton');
|
||||
if (editButton && editButton.classList.contains('active')) {
|
||||
window.location.reload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip other shortcuts if user is typing in an input/textarea
|
||||
if (e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.isContentEditable) {
|
||||
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...');
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (requires Shift on most keyboards)
|
||||
if (e.key === '?') {
|
||||
e.preventDefault();
|
||||
showKeyboardHelp();
|
||||
}
|
||||
|
||||
// J: Move to next row in table (Gmail-style)
|
||||
if (e.key === 'j') {
|
||||
e.preventDefault();
|
||||
navigateTableRow('next');
|
||||
}
|
||||
|
||||
// K: Move to previous row in table (Gmail-style)
|
||||
if (e.key === 'k') {
|
||||
e.preventDefault();
|
||||
navigateTableRow('prev');
|
||||
}
|
||||
|
||||
// Enter: Open selected ticket
|
||||
if (e.key === 'Enter') {
|
||||
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
|
||||
if (selectedRow) {
|
||||
e.preventDefault();
|
||||
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
|
||||
if (ticketLink) {
|
||||
window.location.href = ticketLink.href;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// N: Create new ticket (on dashboard)
|
||||
if (e.key === 'n') {
|
||||
e.preventDefault();
|
||||
const newTicketBtn = document.querySelector('a[href*="/create"]');
|
||||
if (newTicketBtn) {
|
||||
window.location.href = newTicketBtn.href;
|
||||
}
|
||||
}
|
||||
|
||||
// C: Focus comment textarea (on ticket page)
|
||||
if (e.key === 'c') {
|
||||
const commentBox = document.getElementById('newComment');
|
||||
if (commentBox) {
|
||||
e.preventDefault();
|
||||
commentBox.focus();
|
||||
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
// G then D: Go to Dashboard (vim-style)
|
||||
if (e.key === 'g') {
|
||||
window._pendingG = true;
|
||||
setTimeout(() => { window._pendingG = false; }, 1000);
|
||||
}
|
||||
if (e.key === 'd' && window._pendingG) {
|
||||
e.preventDefault();
|
||||
window._pendingG = false;
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
// 1-4: Quick status change on ticket page
|
||||
if (['1', '2', '3', '4'].includes(e.key)) {
|
||||
const statusSelect = document.getElementById('statusSelect');
|
||||
if (statusSelect && !document.querySelector('.modal-overlay')) {
|
||||
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
|
||||
const targetStatus = statusMap[e.key];
|
||||
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
|
||||
if (option && !option.disabled) {
|
||||
e.preventDefault();
|
||||
statusSelect.value = targetStatus;
|
||||
statusSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Track currently selected row for J/K navigation
|
||||
let currentSelectedRowIndex = -1;
|
||||
|
||||
@@ -175,7 +11,6 @@ function navigateTableRow(direction) {
|
||||
const rows = document.querySelectorAll('tbody tr');
|
||||
if (rows.length === 0) return;
|
||||
|
||||
// Remove current selection
|
||||
rows.forEach(row => row.classList.remove('keyboard-selected'));
|
||||
|
||||
if (direction === 'next') {
|
||||
@@ -184,7 +19,6 @@ function navigateTableRow(direction) {
|
||||
currentSelectedRowIndex = Math.max(currentSelectedRowIndex - 1, 0);
|
||||
}
|
||||
|
||||
// Add selection to new row
|
||||
const selectedRow = rows[currentSelectedRowIndex];
|
||||
if (selectedRow) {
|
||||
selectedRow.classList.add('keyboard-selected');
|
||||
@@ -193,59 +27,140 @@ function navigateTableRow(direction) {
|
||||
}
|
||||
|
||||
function showKeyboardHelp() {
|
||||
// Check if help is already showing
|
||||
if (document.getElementById('keyboardHelpModal')) {
|
||||
return;
|
||||
}
|
||||
if (document.getElementById('keyboardHelpModal')) return;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'keyboardHelpModal';
|
||||
modal.className = 'modal-overlay';
|
||||
modal.className = 'lt-modal-overlay';
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-labelledby', 'keyboardHelpModalTitle');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
|
||||
<div class="ascii-frame">
|
||||
<div class="ascii-content">
|
||||
<h3 style="margin: 0 0 1rem 0; color: var(--terminal-green);">KEYBOARD SHORTCUTS</h3>
|
||||
<div class="modal-body" style="padding: 0;">
|
||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Navigation</h4>
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>J</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Next ticket in list</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Previous ticket in list</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Enter</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Open selected ticket</td></tr>
|
||||
<tr><td style="padding: 0.4rem;"><kbd>G</kbd> then <kbd>D</kbd></td><td style="padding: 0.4rem;">Go to Dashboard</td></tr>
|
||||
</table>
|
||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Actions</h4>
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>N</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">New ticket</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>C</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus comment box</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + E</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Toggle Edit Mode</td></tr>
|
||||
<tr><td style="padding: 0.4rem;"><kbd>Ctrl/Cmd + S</kbd></td><td style="padding: 0.4rem;">Save Changes</td></tr>
|
||||
</table>
|
||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Quick Status (Ticket Page)</h4>
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>1</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Open</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>2</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Pending</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>3</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set In Progress</td></tr>
|
||||
<tr><td style="padding: 0.4rem;"><kbd>4</kbd></td><td style="padding: 0.4rem;">Set Closed</td></tr>
|
||||
</table>
|
||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Other</h4>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus Search</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>ESC</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Close Modal / Cancel</td></tr>
|
||||
<tr><td style="padding: 0.4rem;"><kbd>?</kbd></td><td style="padding: 0.4rem;">Show This Help</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer" style="margin-top: 1rem;">
|
||||
<button class="btn btn-secondary" data-action="close-shortcuts-modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-modal lt-modal-sm">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="keyboardHelpModalTitle">KEYBOARD SHORTCUTS</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body">
|
||||
<h4 class="kb-section-heading">Navigation</h4>
|
||||
<table class="kb-shortcuts-table">
|
||||
<tr><td><kbd>J</kbd></td><td>Next ticket in list</td></tr>
|
||||
<tr><td><kbd>K</kbd></td><td>Previous ticket in list</td></tr>
|
||||
<tr><td><kbd>Enter</kbd></td><td>Open selected ticket</td></tr>
|
||||
<tr><td><kbd>G</kbd> then <kbd>D</kbd></td><td>Go to Dashboard</td></tr>
|
||||
</table>
|
||||
<h4 class="kb-section-heading">Actions</h4>
|
||||
<table class="kb-shortcuts-table">
|
||||
<tr><td><kbd>N</kbd></td><td>New ticket</td></tr>
|
||||
<tr><td><kbd>C</kbd></td><td>Focus comment box</td></tr>
|
||||
<tr><td><kbd>Ctrl/Cmd+E</kbd></td><td>Toggle Edit Mode</td></tr>
|
||||
<tr><td><kbd>Ctrl/Cmd+S</kbd></td><td>Save Changes</td></tr>
|
||||
</table>
|
||||
<h4 class="kb-section-heading">Quick Status (Ticket Page)</h4>
|
||||
<table class="kb-shortcuts-table">
|
||||
<tr><td><kbd>1</kbd></td><td>Set Open</td></tr>
|
||||
<tr><td><kbd>2</kbd></td><td>Set Pending</td></tr>
|
||||
<tr><td><kbd>3</kbd></td><td>Set In Progress</td></tr>
|
||||
<tr><td><kbd>4</kbd></td><td>Set Closed</td></tr>
|
||||
</table>
|
||||
<h4 class="kb-section-heading">Other</h4>
|
||||
<table class="kb-shortcuts-table no-margin">
|
||||
<tr><td><kbd>Ctrl/Cmd+K</kbd></td><td>Focus Search</td></tr>
|
||||
<tr><td><kbd>ESC</kbd></td><td>Close Modal / Cancel</td></tr>
|
||||
<tr><td><kbd>?</kbd></td><td>Show This Help</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button class="lt-btn lt-btn-ghost" data-modal-close>CLOSE</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Add event listener for the close button
|
||||
modal.querySelector('[data-action="close-shortcuts-modal"]').addEventListener('click', function() {
|
||||
modal.remove();
|
||||
});
|
||||
lt.modal.open('keyboardHelpModal');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (!window.lt) return;
|
||||
|
||||
// Ctrl+E: Toggle edit mode (ticket pages)
|
||||
lt.keys.on('ctrl+e', function() {
|
||||
const editButton = document.getElementById('editButton');
|
||||
if (editButton) {
|
||||
editButton.click();
|
||||
lt.toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
|
||||
}
|
||||
});
|
||||
|
||||
// Ctrl+S: Save ticket (ticket pages)
|
||||
lt.keys.on('ctrl+s', function() {
|
||||
const editButton = document.getElementById('editButton');
|
||||
if (editButton && editButton.classList.contains('active')) {
|
||||
editButton.click();
|
||||
lt.toast.success('Saving ticket...');
|
||||
}
|
||||
});
|
||||
|
||||
// ?: Show keyboard shortcuts help (lt.keys.initDefaults also handles this, but we override to show our modal)
|
||||
lt.keys.on('?', function() {
|
||||
showKeyboardHelp();
|
||||
});
|
||||
|
||||
// J: Next row
|
||||
lt.keys.on('j', () => navigateTableRow('next'));
|
||||
|
||||
// K: Previous row
|
||||
lt.keys.on('k', () => navigateTableRow('prev'));
|
||||
|
||||
// Enter: Open selected ticket
|
||||
lt.keys.on('enter', function() {
|
||||
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
|
||||
if (selectedRow) {
|
||||
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
|
||||
if (ticketLink) window.location.href = ticketLink.href;
|
||||
}
|
||||
});
|
||||
|
||||
// N: New ticket
|
||||
lt.keys.on('n', function() {
|
||||
const newTicketBtn = document.querySelector('a[href*="/create"]');
|
||||
if (newTicketBtn) window.location.href = newTicketBtn.href;
|
||||
});
|
||||
|
||||
// C: Focus comment box
|
||||
lt.keys.on('c', function() {
|
||||
const commentBox = document.getElementById('newComment');
|
||||
if (commentBox) {
|
||||
commentBox.focus();
|
||||
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
});
|
||||
|
||||
// G then D: Go to Dashboard (vim-style)
|
||||
lt.keys.on('g', function() {
|
||||
window._pendingG = true;
|
||||
setTimeout(() => { window._pendingG = false; }, 1000);
|
||||
});
|
||||
lt.keys.on('d', function() {
|
||||
if (window._pendingG) {
|
||||
window._pendingG = false;
|
||||
window.location.href = '/';
|
||||
}
|
||||
});
|
||||
|
||||
// 1-4: Quick status change on ticket page
|
||||
['1', '2', '3', '4'].forEach(key => {
|
||||
lt.keys.on(key, function() {
|
||||
const statusSelect = document.getElementById('statusSelect');
|
||||
if (statusSelect && !document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) {
|
||||
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
|
||||
const targetStatus = statusMap[key];
|
||||
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
|
||||
if (option && !option.disabled) {
|
||||
statusSelect.value = targetStatus;
|
||||
statusSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -364,7 +364,7 @@ function createEditorToolbar(textareaId, containerId) {
|
||||
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
|
||||
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
|
||||
<span class="toolbar-separator"></span>
|
||||
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">🔗</button>
|
||||
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button>
|
||||
`;
|
||||
|
||||
// Add event delegation for toolbar buttons
|
||||
|
||||
@@ -8,16 +8,13 @@ let userPreferences = {};
|
||||
// Load preferences on page load
|
||||
async function loadUserPreferences() {
|
||||
try {
|
||||
const response = await fetch('/api/user_preferences.php', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await lt.api.get('/api/user_preferences.php');
|
||||
if (data.success) {
|
||||
userPreferences = data.preferences;
|
||||
applyPreferences();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading preferences:', error);
|
||||
lt.toast.error('Error loading preferences');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,34 +91,12 @@ async function saveSettings() {
|
||||
};
|
||||
|
||||
try {
|
||||
// Batch save all preferences in one request
|
||||
const response = await fetch('/api/user_preferences.php', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({ preferences: prefs })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to save preferences');
|
||||
}
|
||||
|
||||
if (typeof toast !== 'undefined') {
|
||||
toast.success('Preferences saved successfully!');
|
||||
}
|
||||
await lt.api.post('/api/user_preferences.php', { preferences: prefs });
|
||||
lt.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);
|
||||
lt.toast.error('Error saving preferences');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,24 +104,18 @@ async function saveSettings() {
|
||||
function openSettingsModal() {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
lt.modal.open('settingsModal');
|
||||
loadUserPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
function closeSettingsModal() {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
lt.modal.close('settingsModal');
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -158,15 +127,10 @@ document.addEventListener('keydown', (e) => {
|
||||
e.preventDefault();
|
||||
openSettingsModal();
|
||||
}
|
||||
|
||||
// ESC to close modal
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal && modal.style.display !== 'none' && modal.style.display !== '') {
|
||||
closeSettingsModal();
|
||||
}
|
||||
}
|
||||
// ESC is handled globally by lt.keys.initDefaults()
|
||||
});
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', loadUserPreferences);
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (window.lt) loadUserPreferences();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,94 +1,22 @@
|
||||
/**
|
||||
* Terminal-style toast notification system with queuing
|
||||
* Deprecated: use lt.toast.* directly (from web_template/base.js).
|
||||
* This shim maintains backwards compatibility while callers are migrated.
|
||||
*/
|
||||
|
||||
// Toast queue management
|
||||
let toastQueue = [];
|
||||
let currentToast = null;
|
||||
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
// Queue if a toast is already showing
|
||||
if (currentToast) {
|
||||
toastQueue.push({ message, type, duration });
|
||||
return;
|
||||
// showToast() shim — used by inline view scripts
|
||||
function showToast(message, type = 'info', duration = 3500) {
|
||||
switch (type) {
|
||||
case 'success': lt.toast.success(message, duration); break;
|
||||
case 'error': lt.toast.error(message, duration); break;
|
||||
case 'warning': lt.toast.warning(message, duration); break;
|
||||
default: lt.toast.info(message, duration); break;
|
||||
}
|
||||
|
||||
displayToast(message, type, duration);
|
||||
}
|
||||
|
||||
function displayToast(message, type, duration) {
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `terminal-toast toast-${type}`;
|
||||
currentToast = toast;
|
||||
|
||||
// Icon based on type
|
||||
const icons = {
|
||||
success: '✓',
|
||||
error: '✗',
|
||||
info: 'ℹ',
|
||||
warning: '⚠'
|
||||
};
|
||||
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'toast-icon';
|
||||
iconSpan.textContent = `[${icons[type] || 'ℹ'}]`;
|
||||
|
||||
const msgSpan = document.createElement('span');
|
||||
msgSpan.className = 'toast-message';
|
||||
msgSpan.textContent = message;
|
||||
|
||||
const closeSpan = document.createElement('span');
|
||||
closeSpan.className = 'toast-close';
|
||||
closeSpan.style.cssText = 'margin-left: auto; cursor: pointer; opacity: 0.7; padding-left: 1rem;';
|
||||
closeSpan.textContent = '[×]';
|
||||
|
||||
toast.appendChild(iconSpan);
|
||||
toast.appendChild(msgSpan);
|
||||
toast.appendChild(closeSpan);
|
||||
|
||||
// Add to document
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Trigger animation
|
||||
setTimeout(() => toast.classList.add('show'), 10);
|
||||
|
||||
// Manual dismiss handler
|
||||
const closeBtn = toast.querySelector('.toast-close');
|
||||
closeBtn.addEventListener('click', () => dismissToast(toast));
|
||||
|
||||
// Auto-remove after duration
|
||||
const timeoutId = setTimeout(() => {
|
||||
dismissToast(toast);
|
||||
}, duration);
|
||||
|
||||
// Store timeout ID for manual dismiss
|
||||
toast.timeoutId = timeoutId;
|
||||
}
|
||||
|
||||
function dismissToast(toast) {
|
||||
// Clear auto-dismiss timeout
|
||||
if (toast.timeoutId) {
|
||||
clearTimeout(toast.timeoutId);
|
||||
}
|
||||
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
currentToast = null;
|
||||
|
||||
// Show next toast in queue
|
||||
if (toastQueue.length > 0) {
|
||||
const next = toastQueue.shift();
|
||||
displayToast(next.message, next.type, next.duration);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Convenience functions
|
||||
// window.toast.* shim — used by JS files
|
||||
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)
|
||||
success: (msg, dur) => lt.toast.success(msg, dur),
|
||||
error: (msg, dur) => lt.toast.error(msg, dur),
|
||||
warning: (msg, dur) => lt.toast.warning(msg, dur),
|
||||
info: (msg, dur) => lt.toast.info(msg, dur),
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// XSS prevention helper
|
||||
// XSS prevention helper — delegates to lt.escHtml() from web_template/base.js
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return lt.escHtml(text);
|
||||
}
|
||||
|
||||
// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats)
|
||||
@@ -12,3 +10,49 @@ function getTicketIdFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a terminal-style confirmation modal using the lt.modal system.
|
||||
* Falls back gracefully if dashboard.js has already defined this function.
|
||||
* @param {string} title - Modal title
|
||||
* @param {string} message - Confirmation message
|
||||
* @param {string} type - 'warning' | 'error' | 'info'
|
||||
* @param {Function} onConfirm - Called when user confirms
|
||||
* @param {Function|null} onCancel - Called when user cancels
|
||||
*/
|
||||
if (typeof showConfirmModal === 'undefined') {
|
||||
window.showConfirmModal = function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
|
||||
const modalId = 'confirmModal' + Date.now();
|
||||
const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' };
|
||||
const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
|
||||
const color = colors[type] || colors.warning;
|
||||
const icon = icons[type] || icons.warning;
|
||||
const safeTitle = lt.escHtml(title);
|
||||
const safeMessage = lt.escHtml(message);
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', `
|
||||
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
|
||||
<div class="lt-modal lt-modal-sm">
|
||||
<div class="lt-modal-header" style="color:${color};">
|
||||
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body text-center">
|
||||
<p class="modal-message">${safeMessage}</p>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
|
||||
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const modal = document.getElementById(modalId);
|
||||
lt.modal.open(modalId);
|
||||
const cleanup = (cb) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (cb) cb(); };
|
||||
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
|
||||
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
||||
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
||||
};
|
||||
}
|
||||
|
||||
15
deploy.sh
15
deploy.sh
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Deploying tinker_tickets to web server..."
|
||||
|
||||
# Deploy to web server
|
||||
echo "Syncing to web server (10.10.10.45)..."
|
||||
rsync -avz --delete --exclude='.git' --exclude='deploy.sh' --exclude='.env' ./ root@10.10.10.45:/var/www/html/tinkertickets/
|
||||
|
||||
# Set proper permissions on the web server
|
||||
echo "Setting proper file permissions..."
|
||||
ssh root@10.10.10.45 "chown -R www-data:www-data /var/www/html/tinkertickets && find /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/tinkertickets -type d -exec chmod 755 {} \;"
|
||||
|
||||
echo "Deployment to web server complete!"
|
||||
echo "Don't forget to commit and push your changes via VS Code when ready."
|
||||
@@ -162,13 +162,5 @@ class Database {
|
||||
return self::getConnection()->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for use in queries (prefer prepared statements)
|
||||
*
|
||||
* @param string $string String to escape
|
||||
* @return string Escaped string
|
||||
*/
|
||||
public static function escape(string $string): string {
|
||||
return self::getConnection()->real_escape_string($string);
|
||||
}
|
||||
// escape() removed — use prepared statements with bind_param() instead
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@ if (!str_starts_with($requestPath, '/api/')) {
|
||||
require_once 'models/UserPreferencesModel.php';
|
||||
$prefsModel = new UserPreferencesModel($conn);
|
||||
$userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null);
|
||||
if ($userTimezone) {
|
||||
// Override system timezone with user preference
|
||||
if ($userTimezone && in_array($userTimezone, DateTimeZone::listIdentifiers())) {
|
||||
// Override system timezone with user preference (validated against known identifiers)
|
||||
date_default_timezone_set($userTimezone);
|
||||
$GLOBALS['config']['TIMEZONE'] = $userTimezone;
|
||||
$now = new DateTime('now', new DateTimeZone($userTimezone));
|
||||
|
||||
@@ -3,22 +3,11 @@
|
||||
* AttachmentModel - Handles ticket file attachments
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
class AttachmentModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct() {
|
||||
$this->conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($this->conn->connect_error) {
|
||||
throw new Exception('Database connection failed: ' . $this->conn->connect_error);
|
||||
}
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,9 +193,4 @@ class AttachmentModel {
|
||||
return in_array($mimeType, $allowedTypes);
|
||||
}
|
||||
|
||||
public function __destruct() {
|
||||
if ($this->conn) {
|
||||
$this->conn->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ class BulkOperationsModel {
|
||||
* @return int|false Operation ID or false on failure
|
||||
*/
|
||||
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) {
|
||||
// Validate ticket IDs to prevent injection via implode
|
||||
$ticketIds = array_values(array_filter(
|
||||
array_map('strval', $ticketIds),
|
||||
fn($id) => preg_match('/^[0-9]+$/', $id)
|
||||
));
|
||||
if (empty($ticketIds)) {
|
||||
return false;
|
||||
}
|
||||
$ticketIdsStr = implode(',', $ticketIds);
|
||||
$totalTickets = count($ticketIds);
|
||||
$parametersJson = $parameters ? json_encode($parameters) : null;
|
||||
|
||||
@@ -71,7 +71,7 @@ class CommentModel {
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
$stmt->bind_param("i", $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
@@ -126,7 +126,8 @@ class CommentModel {
|
||||
private function buildCommentThread($comment, &$allComments) {
|
||||
$comment['replies'] = [];
|
||||
foreach ($allComments as $c) {
|
||||
if ($c['parent_comment_id'] == $comment['comment_id']) {
|
||||
if ($c['parent_comment_id'] == $comment['comment_id']
|
||||
&& isset($allComments[$c['comment_id']])) {
|
||||
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,29 +54,36 @@ class SavedFiltersModel {
|
||||
* 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);
|
||||
$this->conn->begin_transaction();
|
||||
try {
|
||||
// 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()) {
|
||||
$filterId = $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName);
|
||||
$this->conn->commit();
|
||||
return ['success' => true, 'filter_id' => $filterId];
|
||||
}
|
||||
$error = $this->conn->error;
|
||||
$this->conn->rollback();
|
||||
return ['success' => false, 'error' => $error];
|
||||
} catch (Exception $e) {
|
||||
$this->conn->rollback();
|
||||
return ['success' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
$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];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,18 +133,25 @@ class SavedFiltersModel {
|
||||
* Set a filter as default
|
||||
*/
|
||||
public function setDefaultFilter($filterId, $userId) {
|
||||
// First, clear all defaults
|
||||
$this->clearDefaultFilters($userId);
|
||||
$this->conn->begin_transaction();
|
||||
try {
|
||||
$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);
|
||||
$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];
|
||||
if ($stmt->execute()) {
|
||||
$this->conn->commit();
|
||||
return ['success' => true];
|
||||
}
|
||||
$error = $this->conn->error;
|
||||
$this->conn->rollback();
|
||||
return ['success' => false, 'error' => $error];
|
||||
} catch (Exception $e) {
|
||||
$this->conn->rollback();
|
||||
return ['success' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
return ['success' => false, 'error' => $this->conn->error];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -134,7 +134,7 @@ class StatsModel {
|
||||
u.username,
|
||||
COUNT(t.ticket_id) as ticket_count
|
||||
FROM tickets t
|
||||
JOIN users u ON t.assigned_to = u.user_id
|
||||
LEFT JOIN users u ON t.assigned_to = u.user_id
|
||||
WHERE t.status != 'Closed'
|
||||
GROUP BY t.assigned_to
|
||||
ORDER BY ticket_count DESC
|
||||
|
||||
@@ -422,13 +422,41 @@ class TicketModel {
|
||||
'ticket_id' => $ticket_id
|
||||
];
|
||||
} else {
|
||||
// Handle duplicate key (errno 1062) caused by race condition between
|
||||
// the uniqueness SELECT above and this INSERT — regenerate and retry once
|
||||
if ($this->conn->errno === 1062) {
|
||||
$stmt->close();
|
||||
try {
|
||||
$ticket_id = sprintf('%09d', random_int(100000000, 999999999));
|
||||
} catch (Exception $e) {
|
||||
$ticket_id = sprintf('%09d', mt_rand(100000000, 999999999));
|
||||
}
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param(
|
||||
"sssssssiiss",
|
||||
$ticket_id,
|
||||
$ticketData['title'],
|
||||
$ticketData['description'],
|
||||
$status,
|
||||
$priority,
|
||||
$category,
|
||||
$type,
|
||||
$createdBy,
|
||||
$assignedTo,
|
||||
$visibility,
|
||||
$visibilityGroups
|
||||
);
|
||||
if ($stmt->execute()) {
|
||||
return ['success' => true, 'ticket_id' => $ticket_id];
|
||||
}
|
||||
}
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $this->conn->error
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function addComment(int $ticketId, array $commentData): array {
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||
VALUES (?, ?, ?, ?)";
|
||||
|
||||
@@ -27,6 +27,10 @@ class WorkflowModel {
|
||||
WHERE is_active = TRUE";
|
||||
$result = $this->conn->query($sql);
|
||||
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$transitions = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$from = $row['from_status'];
|
||||
@@ -102,6 +106,10 @@ class WorkflowModel {
|
||||
ORDER BY status";
|
||||
$result = $this->conn->query($sql);
|
||||
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statuses = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$statuses[] = $row['status'];
|
||||
|
||||
@@ -11,10 +11,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Create New Ticket</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?v=20260126c">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e">
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// CSRF Token for AJAX requests
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
@@ -23,13 +25,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<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>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -46,7 +48,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<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;">
|
||||
<p class="form-hint">
|
||||
Complete the form below to create a new ticket
|
||||
</p>
|
||||
</div>
|
||||
@@ -60,8 +62,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<!-- 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 class="error-message inline-error">
|
||||
<strong>[ ! ] ERROR:</strong> <?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,7 +90,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
|
||||
<p class="form-hint">
|
||||
Select a template to auto-fill form fields
|
||||
</p>
|
||||
</div>
|
||||
@@ -107,11 +109,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
|
||||
</div>
|
||||
<!-- Duplicate Warning Area -->
|
||||
<div id="duplicateWarning" style="display: none; margin-top: 1rem; padding: 1rem; border: 2px solid var(--terminal-amber); background: rgba(241, 196, 15, 0.1);">
|
||||
<div style="color: var(--terminal-amber); font-weight: bold; margin-bottom: 0.5rem;">
|
||||
<div id="duplicateWarning" class="inline-warning is-hidden" role="alert" aria-live="polite" aria-atomic="true">
|
||||
<div class="text-amber fw-bold duplicate-heading">
|
||||
Possible Duplicates Found
|
||||
</div>
|
||||
<div id="duplicatesList"></div>
|
||||
<div id="duplicatesList" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,7 +185,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
|
||||
<p class="form-hint">
|
||||
Select a user to assign this ticket to
|
||||
</p>
|
||||
</div>
|
||||
@@ -204,13 +206,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<option value="internal">Internal - Specific groups only</option>
|
||||
<option value="confidential">Confidential - Creator, assignee, admins only</option>
|
||||
</select>
|
||||
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
|
||||
<p class="form-hint">
|
||||
Controls who can view this ticket
|
||||
</p>
|
||||
</div>
|
||||
<div id="visibilityGroupsContainer" class="detail-group" style="display: none; margin-top: 1rem;">
|
||||
<div id="visibilityGroupsContainer" class="detail-group is-hidden">
|
||||
<label>Allowed Groups</label>
|
||||
<div class="visibility-groups-list" style="display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 0.5rem;">
|
||||
<div class="visibility-groups-list">
|
||||
<?php
|
||||
// Get all available groups
|
||||
require_once __DIR__ . '/../models/UserModel.php';
|
||||
@@ -218,16 +220,16 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$allGroups = $userModel->getAllGroups();
|
||||
foreach ($allGroups as $group):
|
||||
?>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<label class="group-checkbox-label">
|
||||
<input type="checkbox" name="visibility_groups[]" value="<?php echo htmlspecialchars($group); ?>" class="visibility-group-checkbox">
|
||||
<span class="group-badge"><?php echo htmlspecialchars($group); ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($allGroups)): ?>
|
||||
<span style="color: var(--text-muted);">No groups available</span>
|
||||
<span class="text-muted">No groups available</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p style="color: var(--terminal-amber); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
|
||||
<p class="form-hint-warning">
|
||||
Select which groups can view this ticket
|
||||
</p>
|
||||
</div>
|
||||
@@ -256,8 +258,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<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" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">Cancel</button>
|
||||
<button type="submit" class="btn primary">CREATE TICKET</button>
|
||||
<button type="button" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,7 +277,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
const title = this.value.trim();
|
||||
|
||||
if (title.length < 5) {
|
||||
document.getElementById('duplicateWarning').style.display = 'none';
|
||||
document.getElementById('duplicateWarning').classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -286,30 +288,29 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
});
|
||||
|
||||
function checkForDuplicates(title) {
|
||||
fetch('/api/check_duplicates.php?title=' + encodeURIComponent(title))
|
||||
.then(response => response.json())
|
||||
lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
|
||||
.then(data => {
|
||||
const warningDiv = document.getElementById('duplicateWarning');
|
||||
const listDiv = document.getElementById('duplicatesList');
|
||||
|
||||
if (data.success && data.duplicates && data.duplicates.length > 0) {
|
||||
let html = '<ul style="margin: 0; padding-left: 1.5rem; color: var(--terminal-green);">';
|
||||
let html = '<ul class="duplicate-list">';
|
||||
data.duplicates.forEach(dup => {
|
||||
html += `<li style="margin-bottom: 0.5rem;">
|
||||
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank" style="color: var(--terminal-green);">
|
||||
html += `<li>
|
||||
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank">
|
||||
#${escapeHtml(dup.ticket_id)}
|
||||
</a>
|
||||
- ${escapeHtml(dup.title)}
|
||||
<span style="color: var(--terminal-amber);">(${dup.similarity}% match, ${escapeHtml(dup.status)})</span>
|
||||
<span class="duplicate-meta">(${dup.similarity}% match, ${escapeHtml(dup.status)})</span>
|
||||
</li>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
html += '<p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--terminal-green-dim);">Consider checking these tickets before creating a new one.</p>';
|
||||
html += '<p class="duplicate-hint">Consider checking these tickets before creating a new one.</p>';
|
||||
|
||||
listDiv.innerHTML = html;
|
||||
warningDiv.style.display = 'block';
|
||||
warningDiv.classList.remove('is-hidden');
|
||||
} else {
|
||||
warningDiv.style.display = 'none';
|
||||
warningDiv.classList.add('is-hidden');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -321,9 +322,9 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
const visibility = document.getElementById('visibility').value;
|
||||
const groupsContainer = document.getElementById('visibilityGroupsContainer');
|
||||
if (visibility === 'internal') {
|
||||
groupsContainer.style.display = 'block';
|
||||
groupsContainer.classList.remove('is-hidden');
|
||||
} else {
|
||||
groupsContainer.style.display = 'none';
|
||||
groupsContainer.classList.add('is-hidden');
|
||||
document.querySelectorAll('.visibility-group-checkbox').forEach(cb => cb.checked = false);
|
||||
}
|
||||
}
|
||||
@@ -350,6 +351,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
toggleVisibilityGroups();
|
||||
}
|
||||
});
|
||||
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,60 +12,64 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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?v=20260131e">
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// CSRF Token for AJAX requests
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
// Timezone configuration (from server)
|
||||
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
|
||||
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
|
||||
window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>';
|
||||
window.APP_TIMEZONE = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
|
||||
window.APP_TIMEZONE_OFFSET = <?php echo (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0); ?>; // minutes from UTC
|
||||
window.APP_TIMEZONE_ABBREV = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE_ABBREV'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
|
||||
</script>
|
||||
</head>
|
||||
<body data-categories='<?php echo json_encode($categories); ?>' data-types='<?php echo json_encode($types); ?>'>
|
||||
<body data-categories='<?php echo htmlspecialchars(json_encode($categories), ENT_QUOTES, 'UTF-8'); ?>' data-types='<?php echo htmlspecialchars(json_encode($types), ENT_QUOTES, 'UTF-8'); ?>'>
|
||||
|
||||
<!-- Terminal Boot Sequence -->
|
||||
<div id="boot-sequence" class="boot-overlay">
|
||||
<div id="boot-banner"></div>
|
||||
<pre id="boot-text"></pre>
|
||||
</div>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
function showBootSequence() {
|
||||
const bootText = document.getElementById('boot-text');
|
||||
const bootOverlay = document.getElementById('boot-sequence');
|
||||
|
||||
// Render ASCII banner first, then start boot messages
|
||||
renderResponsiveBanner('#boot-banner', 0);
|
||||
|
||||
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 ✓',
|
||||
'> SYSTEM READY [OK]',
|
||||
''
|
||||
];
|
||||
|
||||
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);
|
||||
// Brief pause after banner renders before boot text begins
|
||||
setTimeout(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (i < messages.length) {
|
||||
bootText.textContent += messages[i] + '\n';
|
||||
i++;
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
bootOverlay.classList.add('boot-overlay--fade-out');
|
||||
setTimeout(() => bootOverlay.remove(), 500);
|
||||
}, 500);
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 80);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Run on first visit only (per session)
|
||||
@@ -78,52 +82,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
</script>
|
||||
<header class="user-header" role="banner">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="app-title">🎫 Tinker Tickets</a>
|
||||
<a href="/" class="app-title">[ TINKER TICKETS ]</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>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
||||
<div class="admin-dropdown">
|
||||
<button class="admin-badge" data-action="toggle-admin-menu">Admin ▼</button>
|
||||
<button class="admin-badge" data-action="toggle-admin-menu" aria-label="Admin menu" aria-haspopup="true" aria-expanded="false">ADMIN ▼</button>
|
||||
<div class="admin-dropdown-content" id="adminDropdown">
|
||||
<a href="/admin/templates">📋 Templates</a>
|
||||
<a href="/admin/workflow">🔄 Workflow</a>
|
||||
<a href="/admin/recurring-tickets">🔁 Recurring Tickets</a>
|
||||
<a href="/admin/custom-fields">📝 Custom Fields</a>
|
||||
<a href="/admin/user-activity">👥 User Activity</a>
|
||||
<a href="/admin/audit-log">📜 Audit Log</a>
|
||||
<a href="/admin/api-keys">🔑 API Keys</a>
|
||||
<a href="/admin/templates">TEMPLATES</a>
|
||||
<a href="/admin/workflow">WORKFLOW</a>
|
||||
<a href="/admin/recurring-tickets">RECURRING</a>
|
||||
<a href="/admin/custom-fields">CUSTOM FIELDS</a>
|
||||
<a href="/admin/user-activity">USER ACTIVITY</a>
|
||||
<a href="/admin/audit-log">AUDIT LOG</a>
|
||||
<a href="/admin/api-keys">API KEYS</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<button class="settings-icon" title="Settings (Alt+S)" data-action="open-settings" aria-label="Settings">⚙</button>
|
||||
<button class="btn btn-small" data-action="manual-refresh" title="Refresh now (auto-refreshes every 5 min)" aria-label="Refresh dashboard">REFRESH</button>
|
||||
<button class="settings-icon" title="Settings (Alt+S)" data-action="open-settings" aria-label="Settings">[ CFG ]</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Collapsible ASCII Banner -->
|
||||
<div class="ascii-banner-wrapper collapsed">
|
||||
<button class="banner-toggle" data-action="toggle-banner">
|
||||
<span class="toggle-icon">▼</span> ASCII Banner
|
||||
</button>
|
||||
<div id="ascii-banner-container" class="banner-content"></div>
|
||||
</div>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
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" id="dashboardLayout">
|
||||
<!-- Left Sidebar with Filters -->
|
||||
@@ -161,7 +144,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
name="category"
|
||||
value="<?php echo $cat; ?>"
|
||||
value="<?php echo htmlspecialchars($cat); ?>"
|
||||
<?php echo in_array($cat, $currentCategories) ? 'checked' : ''; ?>>
|
||||
<?php echo htmlspecialchars($cat); ?>
|
||||
</label>
|
||||
@@ -178,15 +161,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
name="type"
|
||||
value="<?php echo $type; ?>"
|
||||
value="<?php echo htmlspecialchars($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>
|
||||
<button id="apply-filters-btn" class="btn">APPLY FILTERS</button>
|
||||
<button id="clear-filters-btn" class="btn btn-secondary">CLEAR ALL</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -201,42 +184,42 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<div class="stats-widgets">
|
||||
<div class="stats-row">
|
||||
<div class="stat-card stat-open">
|
||||
<div class="stat-icon">📂</div>
|
||||
<div class="stat-icon">[ # ]</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['open_tickets']; ?></div>
|
||||
<div class="stat-label">Open Tickets</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-critical">
|
||||
<div class="stat-icon">🔥</div>
|
||||
<div class="stat-icon">[ ! ]</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['critical']; ?></div>
|
||||
<div class="stat-label">Critical (P1)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-unassigned">
|
||||
<div class="stat-icon">👤</div>
|
||||
<div class="stat-icon">[ @ ]</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['unassigned']; ?></div>
|
||||
<div class="stat-label">Unassigned</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-today">
|
||||
<div class="stat-icon">📅</div>
|
||||
<div class="stat-icon">[ + ]</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['created_today']; ?></div>
|
||||
<div class="stat-label">Created Today</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-resolved">
|
||||
<div class="stat-icon">✓</div>
|
||||
<div class="stat-icon">[ OK ]</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['closed_today']; ?></div>
|
||||
<div class="stat-label">Closed Today</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-time">
|
||||
<div class="stat-icon">⏱</div>
|
||||
<div class="stat-icon">[ t ]</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['avg_resolution_hours']; ?>h</div>
|
||||
<div class="stat-label">Avg Resolution</div>
|
||||
@@ -250,7 +233,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<div class="dashboard-toolbar">
|
||||
<!-- Left: Title + Search -->
|
||||
<div class="toolbar-left">
|
||||
<h1 class="dashboard-title">🎫 Tickets</h1>
|
||||
<h1 class="dashboard-title">[ TICKETS ]</h1>
|
||||
<form method="GET" action="" class="toolbar-search">
|
||||
<!-- Preserve existing parameters -->
|
||||
<?php if (isset($_GET['status'])): ?>
|
||||
@@ -271,13 +254,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
|
||||
<input type="text"
|
||||
name="search"
|
||||
placeholder="🔍 Search tickets..."
|
||||
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" data-action="open-advanced-search" title="Advanced Search">⚙ Advanced</button>
|
||||
<button type="submit" class="btn search-btn">SEARCH</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="open-advanced-search" title="Advanced Search">FILTER</button>
|
||||
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
|
||||
<a href="?" class="clear-search-btn">✗</a>
|
||||
<a href="?" class="clear-search-btn" aria-label="Clear search">[ X ]</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
</div>
|
||||
@@ -285,12 +268,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<!-- Center: Actions + Count -->
|
||||
<div class="toolbar-center">
|
||||
<div class="view-toggle">
|
||||
<button id="tableViewBtn" class="view-btn active" data-action="set-view-mode" data-mode="table" title="Table View" aria-label="Table view" aria-pressed="true">≡</button>
|
||||
<button id="cardViewBtn" class="view-btn" data-action="set-view-mode" data-mode="card" title="Kanban View" aria-label="Kanban view" aria-pressed="false">▦</button>
|
||||
<button id="tableViewBtn" class="view-btn active" data-action="set-view-mode" data-mode="table" title="Table View" aria-label="Table view" aria-pressed="true">[ = ]</button>
|
||||
<button id="cardViewBtn" class="view-btn" data-action="set-view-mode" data-mode="card" title="Kanban View" aria-label="Kanban view" aria-pressed="false">[ # ]</button>
|
||||
</div>
|
||||
<button data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="btn create-ticket">+ New Ticket</button>
|
||||
<button data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="btn create-ticket">+ NEW TICKET</button>
|
||||
<div class="export-dropdown" id="exportDropdown" style="display: none;">
|
||||
<button class="btn" data-action="toggle-export-menu">↓ Export Selected (<span id="exportCount">0</span>)</button>
|
||||
<button class="btn" data-action="toggle-export-menu" aria-label="Export selected tickets" aria-haspopup="true" aria-expanded="false">EXPORT SELECTED (<span id="exportCount">0</span>)</button>
|
||||
<div class="export-dropdown-content" id="exportDropdownContent">
|
||||
<a href="#" data-action="export-tickets" data-format="csv">CSV</a>
|
||||
<a href="#" data-action="export-tickets" data-format="json">JSON</a>
|
||||
@@ -308,23 +291,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
// Previous page button
|
||||
if ($page > 1) {
|
||||
$currentParams['page'] = $page - 1;
|
||||
$prevUrl = '?' . http_build_query($currentParams);
|
||||
echo "<button data-action='navigate' data-url='$prevUrl'>«</button>";
|
||||
$prevUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
||||
echo "<button data-action='navigate' data-url='$prevUrl' aria-label='Previous page'>[ « ]</button>";
|
||||
}
|
||||
|
||||
// Page number buttons
|
||||
for ($i = 1; $i <= $totalPages; $i++) {
|
||||
$activeClass = ($i === $page) ? 'active' : '';
|
||||
$currentParams['page'] = $i;
|
||||
$pageUrl = '?' . http_build_query($currentParams);
|
||||
$pageUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
||||
echo "<button class='$activeClass' data-action='navigate' data-url='$pageUrl'>$i</button>";
|
||||
}
|
||||
|
||||
// Next page button
|
||||
if ($page < $totalPages) {
|
||||
$currentParams['page'] = $page + 1;
|
||||
$nextUrl = '?' . http_build_query($currentParams);
|
||||
echo "<button data-action='navigate' data-url='$nextUrl'>»</button>";
|
||||
$nextUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
||||
echo "<button data-action='navigate' data-url='$nextUrl' aria-label='Next page'>[ » ]</button>";
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
@@ -350,10 +333,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
|
||||
<div class="bulk-actions-inline" style="display: none;">
|
||||
<span id="selected-count">0</span> tickets selected
|
||||
<button data-action="bulk-status" class="btn btn-bulk">Change Status</button>
|
||||
<button data-action="bulk-assign" class="btn btn-bulk">Assign</button>
|
||||
<button data-action="bulk-priority" class="btn btn-bulk">Priority</button>
|
||||
<button data-action="clear-selection" class="btn btn-secondary">Clear</button>
|
||||
<button data-action="bulk-status" class="btn btn-bulk">CHANGE STATUS</button>
|
||||
<button data-action="bulk-assign" class="btn btn-bulk">ASSIGN</button>
|
||||
<button data-action="bulk-priority" class="btn btn-bulk">PRIORITY</button>
|
||||
<button data-action="clear-selection" class="btn btn-secondary">CLEAR</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -392,11 +375,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<?php foreach ($activeFilters as $filter): ?>
|
||||
<span class="filter-badge" data-filter-type="<?php echo htmlspecialchars($filter['type']); ?>" data-filter-value="<?php echo htmlspecialchars($filter['value']); ?>">
|
||||
<?php echo htmlspecialchars($filter['label']); ?>
|
||||
<button type="button" class="filter-remove" data-action="remove-filter" data-filter-type="<?php echo htmlspecialchars($filter['type']); ?>" data-filter-value="<?php echo htmlspecialchars($filter['value']); ?>" title="Remove filter">×</button>
|
||||
<button type="button" class="filter-remove" data-action="remove-filter" data-filter-type="<?php echo htmlspecialchars($filter['type']); ?>" data-filter-value="<?php echo htmlspecialchars($filter['value']); ?>" title="Remove filter" aria-label="Remove <?php echo htmlspecialchars($filter['label']); ?> filter">×</button>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-action="clear-all-filters">Clear All</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-action="clear-all-filters">CLEAR ALL</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -406,11 +389,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<thead>
|
||||
<tr>
|
||||
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
|
||||
<th style="width: 40px;"><input type="checkbox" id="selectAllCheckbox" data-action="toggle-select-all"></th>
|
||||
<th class="col-checkbox" scope="col"><input type="checkbox" id="selectAllCheckbox" data-action="toggle-select-all" aria-label="Select all tickets"></th>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$currentSort = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
|
||||
$currentDir = isset($_GET['dir']) ? $_GET['dir'] : 'desc';
|
||||
$currentDir = (isset($_GET['dir']) && $_GET['dir'] === 'asc') ? 'asc' : 'desc';
|
||||
|
||||
$columns = [
|
||||
'ticket_id' => 'Ticket ID',
|
||||
@@ -428,13 +411,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
|
||||
foreach($columns as $col => $label) {
|
||||
if ($col === '_actions') {
|
||||
echo "<th style='width: 100px; text-align: center;'>$label</th>";
|
||||
echo "<th scope='col' class='col-actions text-center'>$label</th>";
|
||||
} else {
|
||||
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
||||
$sortClass = ($currentSort === $col) ? "sort-$currentDir" : '';
|
||||
$ariaSort = ($currentSort === $col) ? "aria-sort='" . ($currentDir === 'asc' ? 'ascending' : 'descending') . "'" : '';
|
||||
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]);
|
||||
$sortUrl = '?' . http_build_query($sortParams);
|
||||
echo "<th class='$sortClass' data-action='navigate' data-url='$sortUrl'>$label</th>";
|
||||
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
|
||||
echo "<th scope='col' class='$sortClass' data-action='navigate' data-url='$sortUrl' $ariaSort>$label</th>";
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -450,33 +434,34 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
|
||||
// Add checkbox column for admins
|
||||
if ($GLOBALS['currentUser']['is_admin'] ?? false) {
|
||||
echo "<td data-action='toggle-row-checkbox' class='checkbox-cell'><input type='checkbox' class='ticket-checkbox' value='{$row['ticket_id']}' data-action='update-selection'></td>";
|
||||
echo "<td data-action='toggle-row-checkbox' class='checkbox-cell'><input type='checkbox' class='ticket-checkbox' value='" . $row['ticket_id'] . "' data-action='update-selection' aria-label='Select ticket " . $row['ticket_id'] . "'></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($row['category']) . "</td>";
|
||||
echo "<td>" . htmlspecialchars($row['type']) . "</td>";
|
||||
$statusSlug = htmlspecialchars(str_replace(' ', '-', $row['status']), ENT_QUOTES);
|
||||
echo "<td><span class='status-" . $statusSlug . "'>" . htmlspecialchars($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 "<td class='ts-cell' data-ts='" . htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . date('Y-m-d H:i T', strtotime($row['created_at'])) . "'>" . date('Y-m-d H:i', strtotime($row['created_at'])) . "</td>";
|
||||
echo "<td class='ts-cell' data-ts='" . htmlspecialchars($row['updated_at'], ENT_QUOTES, 'UTF-8') . "' title='" . date('Y-m-d H:i T', strtotime($row['updated_at'])) . "'>" . date('Y-m-d H:i', strtotime($row['updated_at'])) . "</td>";
|
||||
// Quick actions column
|
||||
echo "<td class='quick-actions-cell'>";
|
||||
echo "<div class='quick-actions'>";
|
||||
echo "<button data-action='view-ticket' data-ticket-id='{$row['ticket_id']}' class='quick-action-btn' title='View'>👁</button>";
|
||||
echo "<button data-action='quick-status' data-ticket-id='{$row['ticket_id']}' data-status='{$row['status']}' class='quick-action-btn' title='Change Status'>🔄</button>";
|
||||
echo "<button data-action='quick-assign' data-ticket-id='{$row['ticket_id']}' class='quick-action-btn' title='Assign'>👤</button>";
|
||||
echo "<button data-action='view-ticket' data-ticket-id='" . $row['ticket_id'] . "' class='quick-action-btn' title='View' aria-label='View ticket " . $row['ticket_id'] . "'>></button>";
|
||||
echo "<button data-action='quick-status' data-ticket-id='" . (int)$row['ticket_id'] . "' data-status='" . htmlspecialchars($row['status'], ENT_QUOTES) . "' class='quick-action-btn' title='Change Status' aria-label='Change status for ticket " . (int)$row['ticket_id'] . "'>~</button>";
|
||||
echo "<button data-action='quick-assign' data-ticket-id='" . $row['ticket_id'] . "' class='quick-action-btn' title='Assign' aria-label='Assign ticket " . $row['ticket_id'] . "'>@</button>";
|
||||
echo "</div>";
|
||||
echo "</td>";
|
||||
echo "</tr>";
|
||||
}
|
||||
} else {
|
||||
$colspan = ($GLOBALS['currentUser']['is_admin'] ?? false) ? '12' : '11';
|
||||
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 "<tr><td colspan='$colspan' class='dashboard-empty-state'>";
|
||||
echo "<pre class='dashboard-empty-pre'>";
|
||||
echo "╔════════════════════════════════════════╗\n";
|
||||
echo "║ ║\n";
|
||||
echo "║ NO TICKETS FOUND ║\n";
|
||||
@@ -507,17 +492,17 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<div class="ticket-card-main">
|
||||
<div class="ticket-card-title"><?php echo htmlspecialchars($row['title']); ?></div>
|
||||
<div class="ticket-card-meta">
|
||||
<span>📁 <?php echo htmlspecialchars($row['category']); ?></span>
|
||||
<span>👤 <?php echo htmlspecialchars($assignedTo); ?></span>
|
||||
<span>📅 <?php echo date('M j', strtotime($row['updated_at'])); ?></span>
|
||||
<span><?php echo htmlspecialchars($row['category']); ?></span>
|
||||
<span>@ <?php echo htmlspecialchars($assignedTo); ?></span>
|
||||
<span class="ts-cell" data-ts="<?php echo htmlspecialchars($row['updated_at'], ENT_QUOTES, 'UTF-8'); ?>" title="<?php echo date('Y-m-d H:i', strtotime($row['updated_at'])); ?>"><?php echo date('M j', strtotime($row['updated_at'])); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ticket-card-status <?php echo $statusClass; ?>">
|
||||
<?php echo $row['status']; ?>
|
||||
<div class="ticket-card-status <?php echo htmlspecialchars($statusClass); ?>">
|
||||
<?php echo htmlspecialchars($row['status']); ?>
|
||||
</div>
|
||||
<div class="ticket-card-actions">
|
||||
<button data-action="view-ticket" data-ticket-id="<?php echo $row['ticket_id']; ?>" title="View">👁</button>
|
||||
<button data-action="quick-status" data-ticket-id="<?php echo $row['ticket_id']; ?>" data-status="<?php echo $row['status']; ?>" title="Status">🔄</button>
|
||||
<button data-action="view-ticket" data-ticket-id="<?php echo (int)$row['ticket_id']; ?>" title="View" aria-label="View ticket <?php echo (int)$row['ticket_id']; ?>">></button>
|
||||
<button data-action="quick-status" data-ticket-id="<?php echo (int)$row['ticket_id']; ?>" data-status="<?php echo htmlspecialchars($row['status'], ENT_QUOTES); ?>" title="Status" aria-label="Change status for ticket <?php echo (int)$row['ticket_id']; ?>">~</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
@@ -539,7 +524,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<!-- END OUTER FRAME -->
|
||||
|
||||
<!-- Kanban Card View -->
|
||||
<section id="cardView" class="card-view-container" style="display: none;" aria-label="Kanban board view">
|
||||
<section id="cardView" class="card-view-container is-hidden" aria-label="Kanban board view">
|
||||
<div class="kanban-board">
|
||||
<div class="kanban-column" data-status="Open">
|
||||
<div class="kanban-column-header status-Open">
|
||||
@@ -573,17 +558,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
</section>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div class="settings-modal" id="settingsModal" style="display: none;" data-action="close-settings-backdrop" role="dialog" aria-modal="true" aria-labelledby="settingsModalTitle">
|
||||
<div class="settings-content">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="settings-header">
|
||||
<h3 id="settingsModalTitle">⚙ System Preferences</h3>
|
||||
<button class="close-settings" data-action="close-settings" aria-label="Close settings">✗</button>
|
||||
<div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="settingsModalTitle">
|
||||
<div class="lt-modal">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="settingsModalTitle">[ CFG ] SYSTEM PREFERENCES</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close settings">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-body">
|
||||
<div class="lt-modal-body">
|
||||
<!-- Display Preferences -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Display Preferences ══╗</h4>
|
||||
@@ -724,23 +706,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-footer">
|
||||
<button class="btn btn-primary" data-action="save-settings">Save Preferences</button>
|
||||
<button class="btn btn-secondary" data-action="close-settings">Cancel</button>
|
||||
<div class="lt-modal-footer">
|
||||
<button class="lt-btn lt-btn-primary" data-action="save-settings">SAVE PREFERENCES</button>
|
||||
<button class="lt-btn lt-btn-ghost" data-action="close-settings">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Search Modal -->
|
||||
<div class="settings-modal" id="advancedSearchModal" style="display: none;" data-action="close-advanced-search-backdrop" role="dialog" aria-modal="true" aria-labelledby="advancedSearchModalTitle">
|
||||
<div class="settings-content">
|
||||
<div class="settings-header">
|
||||
<h3 id="advancedSearchModalTitle">🔍 Advanced Search</h3>
|
||||
<button class="close-settings" data-action="close-advanced-search" aria-label="Close advanced search">✗</button>
|
||||
<div class="lt-modal-overlay" id="advancedSearchModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="advancedSearchModalTitle">
|
||||
<div class="lt-modal">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="advancedSearchModalTitle">[ FILTER ] ADVANCED SEARCH</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close advanced search">✕</button>
|
||||
</div>
|
||||
|
||||
<form id="advancedSearchForm">
|
||||
<div class="settings-body">
|
||||
<div class="lt-modal-body">
|
||||
<!-- Saved Filters -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Saved Filters ══╗</h4>
|
||||
@@ -751,8 +733,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row setting-row-right">
|
||||
<button type="button" class="btn btn-secondary btn-setting" data-action="save-filter">💾 Save Current</button>
|
||||
<button type="button" class="btn btn-secondary btn-setting" data-action="delete-filter">🗑 Delete Selected</button>
|
||||
<button type="button" class="btn btn-secondary btn-setting" data-action="save-filter">SAVE</button>
|
||||
<button type="button" class="btn btn-secondary btn-setting" data-action="delete-filter">DELETE</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -841,19 +823,21 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-footer">
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="reset-advanced-search">Reset</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="close-advanced-search">Cancel</button>
|
||||
<div class="lt-modal-footer">
|
||||
<button type="submit" class="lt-btn lt-btn-primary">SEARCH</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost" data-action="reset-advanced-search">RESET</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost" data-action="close-advanced-search">CANCEL</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// Initialize lt keyboard defaults (ESC closes modals, Ctrl+K focuses search, ? shows help)
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
// Event delegation for all data-action handlers
|
||||
document.addEventListener('click', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
@@ -878,21 +862,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
openSettingsModal();
|
||||
break;
|
||||
|
||||
case 'close-settings':
|
||||
closeSettingsModal();
|
||||
case 'manual-refresh':
|
||||
lt.autoRefresh.now();
|
||||
break;
|
||||
|
||||
case 'close-settings-backdrop':
|
||||
if (event.target === target) closeSettingsModal();
|
||||
case 'close-settings':
|
||||
closeSettingsModal();
|
||||
break;
|
||||
|
||||
case 'save-settings':
|
||||
saveSettings();
|
||||
break;
|
||||
|
||||
case 'toggle-banner':
|
||||
toggleBanner();
|
||||
break;
|
||||
|
||||
case 'toggle-sidebar':
|
||||
toggleSidebar();
|
||||
@@ -906,10 +887,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
closeAdvancedSearch();
|
||||
break;
|
||||
|
||||
case 'close-advanced-search-backdrop':
|
||||
if (event.target === target) closeAdvancedSearch();
|
||||
break;
|
||||
|
||||
case 'reset-advanced-search':
|
||||
resetAdvancedSearch();
|
||||
break;
|
||||
@@ -1015,7 +992,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
|
||||
// Stat card click handlers for filtering
|
||||
document.querySelectorAll('.stat-card').forEach(card => {
|
||||
card.style.cursor = 'pointer';
|
||||
card.addEventListener('click', function() {
|
||||
const classList = this.classList;
|
||||
let url = '/?';
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
// Helper functions for timeline display
|
||||
function getEventIcon($actionType) {
|
||||
$icons = [
|
||||
'create' => '✨',
|
||||
'update' => '📝',
|
||||
'comment' => '💬',
|
||||
'view' => '👁️',
|
||||
'assign' => '👤',
|
||||
'status_change' => '🔄'
|
||||
'create' => '[ + ]',
|
||||
'update' => '[ ~ ]',
|
||||
'comment' => '[ > ]',
|
||||
'view' => '[ . ]',
|
||||
'assign' => '[ @ ]',
|
||||
'status_change' => '[ ! ]',
|
||||
];
|
||||
return $icons[$actionType] ?? '•';
|
||||
return $icons[$actionType] ?? '[ * ]';
|
||||
}
|
||||
|
||||
function formatAction($event) {
|
||||
@@ -50,20 +50,21 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ticket #<?php echo $ticket['ticket_id']; ?></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?v=20260131e">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260131e">
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260131e"></script>
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// CSRF Token for AJAX requests
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
// Timezone configuration (from server)
|
||||
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
|
||||
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
|
||||
window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>';
|
||||
window.APP_TIMEZONE = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
|
||||
window.APP_TIMEZONE_OFFSET = <?php echo (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0); ?>; // minutes from UTC
|
||||
window.APP_TIMEZONE_ABBREV = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE_ABBREV'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
|
||||
</script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// Store ticket data in a global variable (using json_encode for XSS safety)
|
||||
@@ -80,15 +81,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<body>
|
||||
<header class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<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>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
<button class="settings-icon" title="Settings (Alt+S)" id="settingsBtn" aria-label="Settings">⚙</button>
|
||||
<button class="settings-icon" title="Settings (Alt+S)" id="settingsBtn" aria-label="Settings">[ CFG ]</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</header>
|
||||
@@ -132,7 +133,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
}
|
||||
?>
|
||||
<div class="ticket-age <?php echo $ageClass; ?>" title="Time since last update">
|
||||
<span class="age-icon"><?php echo $ageClass === 'age-critical' ? '⚠️' : ($ageClass === 'age-warning' ? '⏰' : '📅'); ?></span>
|
||||
<span class="age-icon"><?php echo $ageClass === 'age-critical' ? '[ ! ]' : ($ageClass === 'age-warning' ? '[ ~ ]' : '[ t ]'); ?></span>
|
||||
<span class="age-text">Last activity: <?php echo $ageStr; ?> ago</span>
|
||||
</div>
|
||||
<div class="ticket-user-info">
|
||||
@@ -140,13 +141,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$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']));
|
||||
$createdFmt = date('M d, Y H:i', strtotime($ticket['created_at']));
|
||||
echo " on <span class='ts-cell' data-ts='" . htmlspecialchars($ticket['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $createdFmt . "'>" . $createdFmt . "</span>";
|
||||
}
|
||||
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']));
|
||||
$updatedFmt = date('M d, Y H:i', strtotime($ticket['updated_at']));
|
||||
echo " on <span class='ts-cell' data-ts='" . htmlspecialchars($ticket['updated_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $updatedFmt . "'>" . $updatedFmt . "</span>";
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -219,7 +222,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<option value="confidential" <?php echo $currentVisibility == 'confidential' ? 'selected' : ''; ?>>Confidential</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="metadata-field" id="visibilityGroupsField" <?php echo $currentVisibility !== 'internal' ? 'style="display: none;"' : ''; ?>>
|
||||
<div class="metadata-field<?php echo $currentVisibility !== 'internal' ? ' is-hidden' : ''; ?>" id="visibilityGroupsField">
|
||||
<label class="metadata-label metadata-label-cyan">Allowed Groups:</label>
|
||||
<div class="visibility-groups-edit">
|
||||
<?php foreach ($allAvailableGroups as $group):
|
||||
@@ -242,23 +245,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
</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" data-action="update-ticket-status">
|
||||
<option value="<?php echo $ticket['status']; ?>" selected>
|
||||
<?php echo $ticket['status']; ?> (current)
|
||||
<select id="statusSelect" class="editable status-select status-<?php echo str_replace(' ', '-', strtolower($ticket["status"])); ?>" data-field="status" data-action="update-ticket-status" aria-label="Change ticket status">
|
||||
<option value="<?php echo htmlspecialchars($ticket['status']); ?>" selected>
|
||||
<?php echo htmlspecialchars($ticket['status']); ?> (current)
|
||||
</option>
|
||||
<?php foreach ($allowedTransitions as $transition): ?>
|
||||
<option value="<?php echo $transition['to_status']; ?>"
|
||||
<option value="<?php echo htmlspecialchars($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 echo htmlspecialchars($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">Edit Ticket</button>
|
||||
<button id="cloneButton" class="btn btn-secondary" title="Create a copy of this ticket">Clone</button>
|
||||
<button id="editButton" class="btn">EDIT TICKET</button>
|
||||
<button id="cloneButton" class="btn btn-secondary" title="Create a copy of this ticket">CLONE</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,11 +275,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<div class="ascii-section-header">Content Sections</div>
|
||||
<div class="ascii-content">
|
||||
<nav class="ticket-tabs" role="tablist" aria-label="Ticket content sections">
|
||||
<button class="tab-btn active" id="description-tab-btn" data-tab="description" role="tab" aria-selected="true" aria-controls="description-tab">Description</button>
|
||||
<button class="tab-btn" id="comments-tab-btn" data-tab="comments" role="tab" aria-selected="false" aria-controls="comments-tab">Comments</button>
|
||||
<button class="tab-btn" id="attachments-tab-btn" data-tab="attachments" role="tab" aria-selected="false" aria-controls="attachments-tab">Attachments</button>
|
||||
<button class="tab-btn" id="dependencies-tab-btn" data-tab="dependencies" role="tab" aria-selected="false" aria-controls="dependencies-tab">Dependencies</button>
|
||||
<button class="tab-btn" id="activity-tab-btn" data-tab="activity" role="tab" aria-selected="false" aria-controls="activity-tab">Activity</button>
|
||||
<button class="tab-btn active" id="description-tab-btn" data-tab="description" role="tab" aria-selected="true" aria-controls="description-tab">DESCRIPTION</button>
|
||||
<button class="tab-btn" id="comments-tab-btn" data-tab="comments" role="tab" aria-selected="false" aria-controls="comments-tab">COMMENTS</button>
|
||||
<button class="tab-btn" id="attachments-tab-btn" data-tab="attachments" role="tab" aria-selected="false" aria-controls="attachments-tab">ATTACHMENTS</button>
|
||||
<button class="tab-btn" id="dependencies-tab-btn" data-tab="dependencies" role="tab" aria-selected="false" aria-controls="dependencies-tab">DEPENDENCIES</button>
|
||||
<button class="tab-btn" id="activity-tab-btn" data-tab="activity" role="tab" aria-selected="false" aria-controls="activity-tab">ACTIVITY</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -292,7 +295,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<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>
|
||||
<textarea class="editable" data-field="description" disabled><?php echo htmlspecialchars($ticket["description"] ?? ''); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -321,9 +324,9 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<span class="toggle-label">Preview Markdown</span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="addCommentBtn" class="btn">Add Comment</button>
|
||||
<button id="addCommentBtn" class="btn">ADD COMMENT</button>
|
||||
</div>
|
||||
<div id="markdownPreview" class="markdown-preview" style="display: none;"></div>
|
||||
<div id="markdownPreview" class="markdown-preview is-hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -360,18 +363,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>";
|
||||
$dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
|
||||
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited">(edited)</span>' : '';
|
||||
echo "<span class='comment-date'>{$dateStr}{$editedIndicator}</span>";
|
||||
echo "<span class='comment-date'><span class='ts-cell' data-ts='" . htmlspecialchars($comment['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $dateStr . "'>" . $dateStr . "</span>{$editedIndicator}</span>";
|
||||
|
||||
// Action buttons
|
||||
echo "<div class='comment-actions'>";
|
||||
// Reply button (max depth of 3)
|
||||
if ($threadDepth < 3) {
|
||||
echo "<button type='button' class='comment-action-btn reply-btn' data-action='reply-comment' data-comment-id='{$commentId}' data-user=\"" . htmlspecialchars($displayName, ENT_QUOTES) . "\" title='Reply' aria-label='Reply to comment'>↩</button>";
|
||||
echo "<button type='button' class='comment-action-btn reply-btn' data-action='reply-comment' data-comment-id='{$commentId}' data-user=\"" . htmlspecialchars($displayName, ENT_QUOTES) . "\" title='Reply' aria-label='Reply to comment'>[ << ]</button>";
|
||||
}
|
||||
// Edit/Delete buttons for owner or admin
|
||||
if ($canModify) {
|
||||
echo "<button type='button' class='comment-action-btn edit-btn' data-action='edit-comment' data-comment-id='{$commentId}' title='Edit' aria-label='Edit comment'>✏️</button>";
|
||||
echo "<button type='button' class='comment-action-btn delete-btn' data-action='delete-comment' data-comment-id='{$commentId}' title='Delete' aria-label='Delete comment'>🗑️</button>";
|
||||
echo "<button type='button' class='comment-action-btn edit-btn' data-action='edit-comment' data-comment-id='{$commentId}' title='Edit' aria-label='Edit comment'>[ EDIT ]</button>";
|
||||
echo "<button type='button' class='comment-action-btn delete-btn' data-action='delete-comment' data-comment-id='{$commentId}' title='Delete' aria-label='Delete comment'>[ DEL ]</button>";
|
||||
}
|
||||
echo "</div>";
|
||||
|
||||
@@ -420,14 +423,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<h3>Upload Files</h3>
|
||||
<div class="upload-zone" id="uploadZone">
|
||||
<div class="upload-zone-content">
|
||||
<div class="upload-icon">📁</div>
|
||||
<div class="upload-icon">[ + ]</div>
|
||||
<p>Drag and drop files here or click to browse</p>
|
||||
<p class="upload-hint">Max file size: <?php echo $GLOBALS['config']['MAX_UPLOAD_SIZE'] ? number_format($GLOBALS['config']['MAX_UPLOAD_SIZE'] / 1048576, 0) . 'MB' : '10MB'; ?></p>
|
||||
<input type="file" id="fileInput" multiple class="sr-only" aria-label="Upload files">
|
||||
<button type="button" id="browseFilesBtn" class="btn upload-browse-btn">Browse Files</button>
|
||||
<button type="button" id="browseFilesBtn" class="btn upload-browse-btn">BROWSE FILES</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="uploadProgress" class="upload-progress" style="display: none;">
|
||||
<div id="uploadProgress" class="upload-progress is-hidden">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
@@ -461,7 +464,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<option value="relates_to">Relates To</option>
|
||||
<option value="duplicates">Duplicates</option>
|
||||
</select>
|
||||
<button id="addDependencyBtn" class="btn">Add</button>
|
||||
<button id="addDependencyBtn" class="btn" aria-label="Add ticket dependency">ADD</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -496,7 +499,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<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>
|
||||
<?php $eventFmt = date('M d, Y H:i', strtotime($event['created_at'])); ?>
|
||||
<span class="timeline-date ts-cell" data-ts="<?php echo htmlspecialchars($event['created_at'], ENT_QUOTES, 'UTF-8'); ?>" title="<?php echo $eventFmt; ?>"><?php echo $eventFmt; ?></span>
|
||||
</div>
|
||||
<?php if (!empty($event['details'])): ?>
|
||||
<div class="timeline-details">
|
||||
@@ -556,39 +560,36 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
var cloneBtn = document.getElementById('cloneButton');
|
||||
if (cloneBtn) {
|
||||
cloneBtn.addEventListener('click', function() {
|
||||
if (confirm('Create a copy of this ticket? The new ticket will have the same title, description, priority, category, and type.')) {
|
||||
cloneBtn.disabled = true;
|
||||
cloneBtn.textContent = 'Cloning...';
|
||||
showConfirmModal(
|
||||
'Clone Ticket',
|
||||
'Create a copy of this ticket? The new ticket will have the same title, description, priority, category, and type.',
|
||||
'warning',
|
||||
function() {
|
||||
cloneBtn.disabled = true;
|
||||
cloneBtn.textContent = 'Cloning...';
|
||||
|
||||
fetch('/api/clone_ticket.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
lt.api.post('/api/clone_ticket.php', {
|
||||
ticket_id: window.ticketData.ticket_id
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
toast.success('Ticket cloned successfully!');
|
||||
setTimeout(function() {
|
||||
window.location.href = '/ticket/' + data.new_ticket_id;
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error('Failed to clone ticket: ' + (data.error || 'Unknown error'));
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
lt.toast.success('Ticket cloned successfully!');
|
||||
setTimeout(function() {
|
||||
window.location.href = '/ticket/' + data.new_ticket_id;
|
||||
}, 1000);
|
||||
} else {
|
||||
lt.toast.error('Failed to clone ticket: ' + (data.error || 'Unknown error'));
|
||||
cloneBtn.disabled = false;
|
||||
cloneBtn.textContent = 'Clone';
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
lt.toast.error('Failed to clone ticket: ' + error.message);
|
||||
cloneBtn.disabled = false;
|
||||
cloneBtn.textContent = 'Clone';
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
toast.error('Failed to clone ticket: ' + error.message);
|
||||
cloneBtn.disabled = false;
|
||||
cloneBtn.textContent = 'Clone';
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -653,15 +654,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
});
|
||||
}
|
||||
|
||||
// Settings modal backdrop click
|
||||
var settingsModal = document.getElementById('settingsModal');
|
||||
if (settingsModal) {
|
||||
settingsModal.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('settings-modal')) {
|
||||
if (typeof closeSettingsModal === 'function') closeSettingsModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Settings modal backdrop click (lt-modal-overlay handles this via data-modal-close)
|
||||
|
||||
// Handle change events for data-action
|
||||
document.addEventListener('change', function(e) {
|
||||
@@ -688,17 +681,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
</script>
|
||||
|
||||
<!-- Settings Modal (same as dashboard) -->
|
||||
<div class="settings-modal" id="settingsModal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="ticketSettingsTitle">
|
||||
<div class="settings-content">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="settings-header">
|
||||
<h3 id="ticketSettingsTitle">⚙ System Preferences</h3>
|
||||
<button class="close-settings" id="closeSettingsBtn" aria-label="Close settings">✗</button>
|
||||
<div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="ticketSettingsTitle">
|
||||
<div class="lt-modal">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="ticketSettingsTitle">[ CFG ] SYSTEM PREFERENCES</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close settings">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-body">
|
||||
<div class="lt-modal-body">
|
||||
<!-- Display Preferences -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Display Preferences ══╗</h4>
|
||||
@@ -816,13 +806,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-footer">
|
||||
<button class="btn btn-primary" id="saveSettingsBtn">Save Preferences</button>
|
||||
<button class="btn btn-secondary" id="cancelSettingsBtn">Cancel</button>
|
||||
<div class="lt-modal-footer">
|
||||
<button class="lt-btn lt-btn-primary" id="saveSettingsBtn">SAVE PREFERENCES</button>
|
||||
<button class="lt-btn lt-btn-ghost" id="cancelSettingsBtn">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,9 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API Keys - Admin</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">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
@@ -22,35 +24,34 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: API Keys</span>
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
<span class="admin-page-title">Admin: API Keys</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>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
||||
<div class="ascii-frame-outer admin-container">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">API Key Management</div>
|
||||
<div class="ascii-content">
|
||||
<!-- Generate New Key Form -->
|
||||
<div class="ascii-frame-inner" style="margin-bottom: 1.5rem;">
|
||||
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">Generate New API Key</h3>
|
||||
<form id="generateKeyForm" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end;">
|
||||
<div style="flex: 1; min-width: 200px;">
|
||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber); margin-bottom: 0.25rem;">Key Name *</label>
|
||||
<input type="text" id="keyName" required placeholder="e.g., CI/CD Pipeline"
|
||||
style="width: 100%; padding: 0.5rem; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
|
||||
<div class="ascii-frame-inner">
|
||||
<h3 class="admin-section-title">Generate New API Key</h3>
|
||||
<form id="generateKeyForm" class="admin-form-row">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="keyName">Key Name *</label>
|
||||
<input type="text" id="keyName" required placeholder="e.g., CI/CD Pipeline" class="admin-input">
|
||||
</div>
|
||||
<div style="min-width: 150px;">
|
||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber); margin-bottom: 0.25rem;">Expires In</label>
|
||||
<select id="expiresIn" style="width: 100%; padding: 0.5rem; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="expiresIn">Expires In</label>
|
||||
<select id="expiresIn" class="admin-input">
|
||||
<option value="">Never</option>
|
||||
<option value="30">30 days</option>
|
||||
<option value="90">90 days</option>
|
||||
@@ -59,28 +60,28 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn">Generate Key</button>
|
||||
<button type="submit" class="btn">GENERATE KEY</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- New Key Display (hidden by default) -->
|
||||
<div id="newKeyDisplay" class="ascii-frame-inner" style="display: none; margin-bottom: 1.5rem; background: rgba(255, 176, 0, 0.1); border-color: var(--terminal-amber);">
|
||||
<h3 style="color: var(--terminal-amber); margin-bottom: 0.5rem;">New API Key Generated</h3>
|
||||
<p style="color: var(--priority-1); margin-bottom: 1rem; font-size: 0.9rem;">
|
||||
<div id="newKeyDisplay" class="ascii-frame-inner key-generated-alert is-hidden">
|
||||
<h3 class="admin-section-title">New API Key Generated</h3>
|
||||
<p class="text-danger text-sm mb-1">
|
||||
Copy this key now. You won't be able to see it again!
|
||||
</p>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<input type="text" id="newKeyValue" readonly
|
||||
style="flex: 1; padding: 0.75rem; font-family: var(--font-mono); font-size: 0.85rem; background: var(--bg-primary); border: 2px solid var(--terminal-green); color: var(--terminal-green);">
|
||||
<button data-action="copy-api-key" class="btn" title="Copy to clipboard">Copy</button>
|
||||
<div class="admin-form-row">
|
||||
<input type="text" id="newKeyValue" readonly class="admin-input">
|
||||
<button data-action="copy-api-key" class="btn" title="Copy to clipboard">COPY</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Keys Table -->
|
||||
<div class="ascii-frame-inner">
|
||||
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">Existing API Keys</h3>
|
||||
<table style="width: 100%; font-size: 0.9rem;">
|
||||
<h3 class="admin-section-title">Existing API Keys</h3>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -96,50 +97,45 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<tbody>
|
||||
<?php if (empty($apiKeys)): ?>
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
||||
No API keys found. Generate one above.
|
||||
</td>
|
||||
<td colspan="8" class="empty-state">No API keys found. Generate one above.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($apiKeys as $key): ?>
|
||||
<tr id="key-row-<?php echo $key['api_key_id']; ?>">
|
||||
<td><?php echo htmlspecialchars($key['key_name']); ?></td>
|
||||
<td style="font-family: var(--font-mono);">
|
||||
<td class="mono">
|
||||
<code><?php echo htmlspecialchars($key['key_prefix']); ?>...</code>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown'); ?></td>
|
||||
<td style="white-space: nowrap;"><?php echo date('Y-m-d H:i', strtotime($key['created_at'])); ?></td>
|
||||
<td style="white-space: nowrap;">
|
||||
<td class="nowrap"><?php echo date('Y-m-d H:i', strtotime($key['created_at'])); ?></td>
|
||||
<td class="nowrap">
|
||||
<?php if ($key['expires_at']): ?>
|
||||
<?php
|
||||
$expired = strtotime($key['expires_at']) < time();
|
||||
$color = $expired ? 'var(--priority-1)' : 'var(--terminal-green)';
|
||||
?>
|
||||
<span style="color: <?php echo $color; ?>;">
|
||||
<?php $expired = strtotime($key['expires_at']) < time(); ?>
|
||||
<span class="<?php echo $expired ? 'text-danger' : ''; ?>">
|
||||
<?php echo date('Y-m-d', strtotime($key['expires_at'])); ?>
|
||||
<?php if ($expired): ?> (Expired)<?php endif; ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span style="color: var(--terminal-cyan);">Never</span>
|
||||
<span class="text-cyan">Never</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td style="white-space: nowrap;">
|
||||
<td class="nowrap">
|
||||
<?php echo $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never'; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($key['is_active']): ?>
|
||||
<span style="color: var(--status-open);">Active</span>
|
||||
<span class="text-open">Active</span>
|
||||
<?php else: ?>
|
||||
<span style="color: var(--status-closed);">Revoked</span>
|
||||
<span class="text-closed">Revoked</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($key['is_active']): ?>
|
||||
<button data-action="revoke-key" data-id="<?php echo $key['api_key_id']; ?>" class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;">
|
||||
Revoke
|
||||
<button data-action="revoke-key" data-id="<?php echo $key['api_key_id']; ?>" class="btn btn-secondary btn-small">
|
||||
REVOKE
|
||||
</button>
|
||||
<?php else: ?>
|
||||
<span style="color: var(--text-muted);">-</span>
|
||||
<span class="text-muted">-</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -147,14 +143,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Usage Info -->
|
||||
<div class="ascii-frame-inner" style="margin-top: 1.5rem;">
|
||||
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">API Usage</h3>
|
||||
<p style="margin-bottom: 0.5rem;">Include the API key in your requests using the Authorization header:</p>
|
||||
<pre style="background: var(--bg-primary); padding: 1rem; border: 1px solid var(--terminal-green); overflow-x: auto;"><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
||||
<p style="margin-top: 1rem; font-size: 0.9rem; color: var(--text-muted);">
|
||||
<div class="ascii-frame-inner">
|
||||
<h3 class="admin-section-title">API Usage</h3>
|
||||
<p>Include the API key in your requests using the Authorization header:</p>
|
||||
<pre class="admin-code-block"><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
||||
<p class="text-muted text-sm">
|
||||
API keys provide programmatic access to create and manage tickets. Keep your keys secure and rotate them regularly.
|
||||
</p>
|
||||
</div>
|
||||
@@ -185,40 +182,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
const expiresIn = document.getElementById('expiresIn').value;
|
||||
|
||||
if (!keyName) {
|
||||
showToast('Please enter a key name', 'error');
|
||||
lt.toast.error('Please enter a key name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/generate_api_key.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
key_name: keyName,
|
||||
expires_in_days: expiresIn || null
|
||||
})
|
||||
const data = await lt.api.post('/api/generate_api_key.php', {
|
||||
key_name: keyName,
|
||||
expires_in_days: expiresIn || null
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Show the new key
|
||||
document.getElementById('newKeyValue').value = data.api_key;
|
||||
document.getElementById('newKeyDisplay').style.display = 'block';
|
||||
document.getElementById('newKeyDisplay').classList.remove('is-hidden');
|
||||
document.getElementById('keyName').value = '';
|
||||
|
||||
showToast('API key generated successfully', 'success');
|
||||
lt.toast.success('API key generated successfully');
|
||||
|
||||
// Reload page after 5 seconds to show new key in table
|
||||
setTimeout(() => location.reload(), 5000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to generate API key', 'error');
|
||||
lt.toast.error(data.error || 'Failed to generate API key');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error generating API key: ' + error.message, 'error');
|
||||
lt.toast.error('Error generating API key: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -226,35 +214,24 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
const keyInput = document.getElementById('newKeyValue');
|
||||
keyInput.select();
|
||||
document.execCommand('copy');
|
||||
showToast('API key copied to clipboard', 'success');
|
||||
lt.toast.success('API key copied to clipboard');
|
||||
}
|
||||
|
||||
async function revokeKey(keyId) {
|
||||
if (!confirm('Are you sure you want to revoke this API key? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/revoke_api_key.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({ key_id: keyId })
|
||||
function revokeKey(keyId) {
|
||||
showConfirmModal('Revoke API Key', 'Are you sure you want to revoke this API key? This action cannot be undone.', 'error', function() {
|
||||
lt.api.post('/api/revoke_api_key.php', { key_id: keyId })
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
lt.toast.success('API key revoked successfully');
|
||||
location.reload();
|
||||
} else {
|
||||
lt.toast.error(data.error || 'Failed to revoke API key');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
lt.toast.error('Error revoking API key: ' + error.message);
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast('API key revoked successfully', 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
showToast(data.error || 'Failed to revoke API key', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error revoking API key: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?php
|
||||
// Admin view for browsing audit logs
|
||||
// Receives $auditLogs, $totalPages, $page, $filters from controller
|
||||
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -9,24 +12,29 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Audit Log - Admin</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">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Audit Log</span>
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
<span class="admin-page-title">Admin: Audit Log</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>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer" style="max-width: 1400px; margin: 2rem auto;">
|
||||
<div class="ascii-frame-outer admin-container-wide">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
@@ -34,10 +42,10 @@
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<!-- Filters -->
|
||||
<form method="GET" style="display: flex; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap;">
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Action Type</label>
|
||||
<select name="action_type" class="setting-select">
|
||||
<form method="GET" class="admin-form-row">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="action_type">Action Type</label>
|
||||
<select name="action_type" id="action_type" class="admin-input">
|
||||
<option value="">All Actions</option>
|
||||
<option value="create" <?php echo ($filters['action_type'] ?? '') === 'create' ? 'selected' : ''; ?>>Create</option>
|
||||
<option value="update" <?php echo ($filters['action_type'] ?? '') === 'update' ? 'selected' : ''; ?>>Update</option>
|
||||
@@ -49,9 +57,9 @@
|
||||
<option value="security" <?php echo ($filters['action_type'] ?? '') === 'security' ? 'selected' : ''; ?>>Security</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">User</label>
|
||||
<select name="user_id" class="setting-select">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="user_id">User</label>
|
||||
<select name="user_id" id="user_id" class="admin-input">
|
||||
<option value="">All Users</option>
|
||||
<?php if (isset($users)): foreach ($users as $user): ?>
|
||||
<option value="<?php echo $user['user_id']; ?>" <?php echo ($filters['user_id'] ?? '') == $user['user_id'] ? 'selected' : ''; ?>>
|
||||
@@ -60,22 +68,23 @@
|
||||
<?php endforeach; endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date From</label>
|
||||
<input type="date" name="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="setting-select">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="date_from">Date From</label>
|
||||
<input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="admin-input">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date To</label>
|
||||
<input type="date" name="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="setting-select">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="date_to">Date To</label>
|
||||
<input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="admin-input">
|
||||
</div>
|
||||
<div style="display: flex; align-items: flex-end;">
|
||||
<button type="submit" class="btn">Filter</button>
|
||||
<a href="?" class="btn btn-secondary" style="margin-left: 0.5rem;">Reset</a>
|
||||
<div class="admin-form-actions">
|
||||
<button type="submit" class="btn">FILTER</button>
|
||||
<a href="?" class="btn btn-secondary">RESET</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Log Table -->
|
||||
<table style="width: 100%; font-size: 0.9rem;">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
@@ -90,34 +99,32 @@
|
||||
<tbody>
|
||||
<?php if (empty($auditLogs)): ?>
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
||||
No audit log entries found.
|
||||
</td>
|
||||
<td colspan="7" class="empty-state">No audit log entries found.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($auditLogs as $log): ?>
|
||||
<tr>
|
||||
<td style="white-space: nowrap;"><?php echo date('Y-m-d H:i:s', strtotime($log['created_at'])); ?></td>
|
||||
<td class="nowrap"><?php echo date('Y-m-d H:i:s', strtotime($log['created_at'])); ?></td>
|
||||
<td><?php echo htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System'); ?></td>
|
||||
<td>
|
||||
<span style="color: var(--terminal-amber);"><?php echo htmlspecialchars($log['action_type']); ?></span>
|
||||
<span class="text-amber"><?php echo htmlspecialchars($log['action_type']); ?></span>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($log['entity_type'] ?? '-'); ?></td>
|
||||
<td>
|
||||
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?>
|
||||
<a href="/ticket/<?php echo htmlspecialchars($log['entity_id']); ?>" style="color: var(--terminal-green);">
|
||||
<a href="/ticket/<?php echo htmlspecialchars($log['entity_id']); ?>" class="text-green">
|
||||
<?php echo htmlspecialchars($log['entity_id']); ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<?php echo htmlspecialchars($log['entity_id'] ?? '-'); ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">
|
||||
<td class="td-truncate">
|
||||
<?php
|
||||
if ($log['details']) {
|
||||
$details = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
|
||||
if (is_array($details)) {
|
||||
echo '<code style="font-size: 0.8rem;">' . htmlspecialchars(json_encode($details)) . '</code>';
|
||||
echo '<code>' . htmlspecialchars(json_encode($details)) . '</code>';
|
||||
} else {
|
||||
echo htmlspecialchars($log['details']);
|
||||
}
|
||||
@@ -126,22 +133,23 @@
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
<td style="white-space: nowrap; font-size: 0.85rem;"><?php echo htmlspecialchars($log['ip_address'] ?? '-'); ?></td>
|
||||
<td class="nowrap text-sm"><?php echo htmlspecialchars($log['ip_address'] ?? '-'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<div class="pagination" style="margin-top: 1rem; text-align: center;">
|
||||
<div class="pagination">
|
||||
<?php
|
||||
$params = $_GET;
|
||||
for ($i = 1; $i <= min($totalPages, 10); $i++) {
|
||||
$params['page'] = $i;
|
||||
$activeClass = ($i == $page) ? 'active' : '';
|
||||
$url = '?' . http_build_query($params);
|
||||
$url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
|
||||
echo "<a href='$url' class='btn btn-small $activeClass'>$i</a> ";
|
||||
}
|
||||
if ($totalPages > 10) {
|
||||
@@ -153,5 +161,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Custom Fields - Admin</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">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
@@ -21,30 +24,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Custom Fields</span>
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
<span class="admin-page-title">Admin: Custom Fields</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>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
||||
<div class="ascii-frame-outer admin-container">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Custom Fields Management</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">Custom Field Definitions</h2>
|
||||
<button data-action="show-create-modal" class="btn">+ New Field</button>
|
||||
<div class="admin-header-row">
|
||||
<h2>Custom Field Definitions</h2>
|
||||
<button data-action="show-create-modal" class="btn">+ NEW FIELD</button>
|
||||
</div>
|
||||
|
||||
<table style="width: 100%;">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
@@ -60,9 +64,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<tbody>
|
||||
<?php if (empty($customFields)): ?>
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
||||
No custom fields defined.
|
||||
</td>
|
||||
<td colspan="8" class="empty-state">No custom fields defined.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($customFields as $field): ?>
|
||||
@@ -74,33 +76,34 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<td><?php echo htmlspecialchars($field['category'] ?? 'All'); ?></td>
|
||||
<td><?php echo $field['is_required'] ? 'Yes' : 'No'; ?></td>
|
||||
<td>
|
||||
<span style="color: <?php echo $field['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
|
||||
<span class="<?php echo $field['is_active'] ? 'text-open' : 'text-closed'; ?>">
|
||||
<?php echo $field['is_active'] ? 'Active' : 'Inactive'; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button data-action="edit-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small">Edit</button>
|
||||
<button data-action="delete-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small btn-danger">Delete</button>
|
||||
<button data-action="edit-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small">EDIT</button>
|
||||
<button data-action="delete-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div class="settings-modal" id="fieldModal" style="display: none;" data-action="close-modal-backdrop">
|
||||
<div class="settings-content" style="max-width: 500px;">
|
||||
<div class="settings-header">
|
||||
<h3 id="modalTitle">Create Custom Field</h3>
|
||||
<button class="close-settings" data-action="close-modal">×</button>
|
||||
<div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||
<div class="lt-modal lt-modal-sm">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="modalTitle">Create Custom Field</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<form id="fieldForm">
|
||||
<input type="hidden" id="field_id" name="field_id">
|
||||
<div class="settings-body">
|
||||
<div class="lt-modal-body">
|
||||
<div class="setting-row">
|
||||
<label for="field_name">Field Name * (internal)</label>
|
||||
<input type="text" id="field_name" name="field_name" required pattern="[a-z_]+" placeholder="e.g., server_name">
|
||||
@@ -120,7 +123,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<option value="number">Number</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row" id="options_row" style="display: none;">
|
||||
<div class="setting-row is-hidden" id="options_row">
|
||||
<label for="field_options">Options (one per line)</label>
|
||||
<textarea id="field_options" name="field_options" rows="4" placeholder="Option 1 Option 2 Option 3"></textarea>
|
||||
</div>
|
||||
@@ -146,15 +149,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-footer">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
|
||||
<div class="lt-modal-footer">
|
||||
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
function showCreateModal() {
|
||||
document.getElementById('modalTitle').textContent = 'Create Custom Field';
|
||||
@@ -162,11 +164,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
document.getElementById('field_id').value = '';
|
||||
document.getElementById('is_active').checked = true;
|
||||
toggleOptionsField();
|
||||
document.getElementById('fieldModal').style.display = 'flex';
|
||||
lt.modal.open('fieldModal');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('fieldModal').style.display = 'none';
|
||||
lt.modal.close('fieldModal');
|
||||
}
|
||||
|
||||
// Event delegation for data-action handlers
|
||||
@@ -179,12 +181,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
case 'show-create-modal':
|
||||
showCreateModal();
|
||||
break;
|
||||
case 'close-modal':
|
||||
closeModal();
|
||||
break;
|
||||
case 'close-modal-backdrop':
|
||||
if (event.target === target) closeModal();
|
||||
break;
|
||||
case 'edit-field':
|
||||
editField(target.dataset.id);
|
||||
break;
|
||||
@@ -208,16 +204,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
saveField(e);
|
||||
});
|
||||
|
||||
// Close modal on ESC key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
|
||||
function toggleOptionsField() {
|
||||
const type = document.getElementById('field_type').value;
|
||||
document.getElementById('options_row').style.display = type === 'select' ? 'block' : 'none';
|
||||
document.getElementById('options_row').classList.toggle('is-hidden', type !== 'select');
|
||||
}
|
||||
|
||||
function saveField(e) {
|
||||
@@ -239,30 +230,19 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
data.field_options = { options: options };
|
||||
}
|
||||
|
||||
const method = data.field_id ? 'PUT' : 'POST';
|
||||
const url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : '');
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
const apiCall = data.field_id ? lt.api.put(url, data) : lt.api.post(url, data);
|
||||
apiCall.then(result => {
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to save');
|
||||
lt.toast.error(result.error || 'Failed to save');
|
||||
}
|
||||
});
|
||||
}).catch(err => lt.toast.error('Failed to save'));
|
||||
}
|
||||
|
||||
function editField(id) {
|
||||
fetch('/api/custom_fields.php?id=' + id)
|
||||
.then(r => r.json())
|
||||
lt.api.get('/api/custom_fields.php?id=' + id)
|
||||
.then(data => {
|
||||
if (data.success && data.field) {
|
||||
const f = data.field;
|
||||
@@ -279,20 +259,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
document.getElementById('field_options').value = f.field_options.options.join('\n');
|
||||
}
|
||||
document.getElementById('modalTitle').textContent = 'Edit Custom Field';
|
||||
document.getElementById('fieldModal').style.display = 'flex';
|
||||
lt.modal.open('fieldModal');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteField(id) {
|
||||
if (!confirm('Delete this custom field? All values will be lost.')) return;
|
||||
fetch('/api/custom_fields.php?id=' + id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
showConfirmModal('Delete Custom Field', 'Delete this custom field? All values will be lost.', 'error', function() {
|
||||
lt.api.delete('/api/custom_fields.php?id=' + id)
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
else lt.toast.error(data.error || 'Failed to delete');
|
||||
}).catch(err => lt.toast.error('Failed to delete'));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Recurring Tickets - Admin</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">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
@@ -21,30 +24,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Recurring Tickets</span>
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
<span class="admin-page-title">Admin: Recurring 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>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
||||
<div class="ascii-frame-outer admin-container">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Recurring Tickets Management</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">Scheduled Tickets</h2>
|
||||
<button data-action="show-create-modal" class="btn">+ New Recurring Ticket</button>
|
||||
<div class="admin-header-row">
|
||||
<h2>Scheduled Tickets</h2>
|
||||
<button data-action="show-create-modal" class="btn">+ NEW RECURRING TICKET</button>
|
||||
</div>
|
||||
|
||||
<table style="width: 100%;">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
@@ -60,9 +64,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<tbody>
|
||||
<?php if (empty($recurringTickets)): ?>
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
||||
No recurring tickets configured.
|
||||
</td>
|
||||
<td colspan="8" class="empty-state">No recurring tickets configured.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($recurringTickets as $rt): ?>
|
||||
@@ -79,50 +81,51 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$schedule .= ' (Day ' . $rt['schedule_day'] . ')';
|
||||
}
|
||||
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
|
||||
echo $schedule;
|
||||
echo htmlspecialchars($schedule);
|
||||
?>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($rt['category']); ?></td>
|
||||
<td><?php echo htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned'); ?></td>
|
||||
<td><?php echo date('M d, Y H:i', strtotime($rt['next_run_at'])); ?></td>
|
||||
<td class="nowrap"><?php echo date('M d, Y H:i', strtotime($rt['next_run_at'])); ?></td>
|
||||
<td>
|
||||
<span style="color: <?php echo $rt['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
|
||||
<span class="<?php echo $rt['is_active'] ? 'text-open' : 'text-closed'; ?>">
|
||||
<?php echo $rt['is_active'] ? 'Active' : 'Inactive'; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button data-action="edit-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">Edit</button>
|
||||
<button data-action="edit-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">EDIT</button>
|
||||
<button data-action="toggle-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">
|
||||
<?php echo $rt['is_active'] ? 'Disable' : 'Enable'; ?>
|
||||
<?php echo $rt['is_active'] ? 'DISABLE' : 'ENABLE'; ?>
|
||||
</button>
|
||||
<button data-action="delete-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small btn-danger">Delete</button>
|
||||
<button data-action="delete-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div class="settings-modal" id="recurringModal" style="display: none;" data-action="close-modal-backdrop">
|
||||
<div class="settings-content" style="max-width: 800px; width: 90%;">
|
||||
<div class="settings-header">
|
||||
<h3 id="modalTitle">Create Recurring Ticket</h3>
|
||||
<button class="close-settings" data-action="close-modal">×</button>
|
||||
<div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||
<div class="lt-modal lt-modal-lg">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="modalTitle">Create Recurring Ticket</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<form id="recurringForm">
|
||||
<input type="hidden" id="recurring_id" name="recurring_id">
|
||||
<div class="settings-body">
|
||||
<div class="lt-modal-body">
|
||||
<div class="setting-row">
|
||||
<label for="title_template">Title Template *</label>
|
||||
<input type="text" id="title_template" name="title_template" required style="width: 100%;" placeholder="Use {{date}}, {{month}}, etc.">
|
||||
<input type="text" id="title_template" name="title_template" required placeholder="Use {{date}}, {{month}}, etc.">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="description_template">Description Template</label>
|
||||
<textarea id="description_template" name="description_template" rows="8" style="width: 100%; min-height: 150px;"></textarea>
|
||||
<textarea id="description_template" name="description_template" rows="8"></textarea>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="schedule_type">Schedule Type *</label>
|
||||
@@ -132,7 +135,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row" id="schedule_day_row" style="display: none;">
|
||||
<div class="setting-row is-hidden" id="schedule_day_row">
|
||||
<label for="schedule_day">Schedule Day</label>
|
||||
<select id="schedule_day" name="schedule_day"></select>
|
||||
</div>
|
||||
@@ -140,7 +143,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<label for="schedule_time">Schedule Time *</label>
|
||||
<input type="time" id="schedule_time" name="schedule_time" value="09:00" required>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
|
||||
<div class="setting-grid-2">
|
||||
<div class="setting-row setting-row-compact">
|
||||
<label for="category">Category</label>
|
||||
<select id="category" name="category">
|
||||
@@ -181,26 +184,25 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-footer">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
|
||||
<div class="lt-modal-footer">
|
||||
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
function showCreateModal() {
|
||||
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
|
||||
document.getElementById('recurringForm').reset();
|
||||
document.getElementById('recurring_id').value = '';
|
||||
updateScheduleOptions();
|
||||
document.getElementById('recurringModal').style.display = 'flex';
|
||||
lt.modal.open('recurringModal');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('recurringModal').style.display = 'none';
|
||||
lt.modal.close('recurringModal');
|
||||
}
|
||||
|
||||
// Event delegation for data-action handlers
|
||||
@@ -213,12 +215,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
case 'show-create-modal':
|
||||
showCreateModal();
|
||||
break;
|
||||
case 'close-modal':
|
||||
closeModal();
|
||||
break;
|
||||
case 'close-modal-backdrop':
|
||||
if (event.target === target) closeModal();
|
||||
break;
|
||||
case 'edit-recurring':
|
||||
editRecurring(target.dataset.id);
|
||||
break;
|
||||
@@ -245,12 +241,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
saveRecurring(e);
|
||||
});
|
||||
|
||||
// Close modal on ESC key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
|
||||
function updateScheduleOptions() {
|
||||
const type = document.getElementById('schedule_type').value;
|
||||
@@ -260,15 +251,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
daySelect.innerHTML = '';
|
||||
|
||||
if (type === 'daily') {
|
||||
dayRow.style.display = 'none';
|
||||
dayRow.classList.add('is-hidden');
|
||||
} else if (type === 'weekly') {
|
||||
dayRow.style.display = 'flex';
|
||||
dayRow.classList.remove('is-hidden');
|
||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
days.forEach((day, i) => {
|
||||
daySelect.innerHTML += `<option value="${i + 1}">${day}</option>`;
|
||||
});
|
||||
} else if (type === 'monthly') {
|
||||
dayRow.style.display = 'flex';
|
||||
dayRow.classList.remove('is-hidden');
|
||||
for (let i = 1; i <= 28; i++) {
|
||||
daySelect.innerHTML += `<option value="${i}">Day ${i}</option>`;
|
||||
}
|
||||
@@ -280,53 +271,37 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
const form = new FormData(document.getElementById('recurringForm'));
|
||||
const data = Object.fromEntries(form);
|
||||
|
||||
const method = data.recurring_id ? 'PUT' : 'POST';
|
||||
const url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : '');
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const apiCall = data.recurring_id ? lt.api.put(url, data) : lt.api.post(url, data);
|
||||
apiCall.then(result => {
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to save');
|
||||
lt.toast.error(result.error || 'Failed to save');
|
||||
}
|
||||
});
|
||||
}).catch(err => lt.toast.error('Failed to save'));
|
||||
}
|
||||
|
||||
function toggleRecurring(id) {
|
||||
fetch('/api/manage_recurring.php?action=toggle&id=' + id, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
||||
})
|
||||
.then(r => r.json())
|
||||
lt.api.post('/api/manage_recurring.php?action=toggle&id=' + id)
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
});
|
||||
else lt.toast.error(data.error || 'Failed to toggle');
|
||||
}).catch(err => lt.toast.error('Failed to toggle'));
|
||||
}
|
||||
|
||||
function deleteRecurring(id) {
|
||||
if (!confirm('Delete this recurring ticket schedule?')) return;
|
||||
fetch('/api/manage_recurring.php?id=' + id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
showConfirmModal('Delete Schedule', 'Delete this recurring ticket schedule?', 'error', function() {
|
||||
lt.api.delete('/api/manage_recurring.php?id=' + id)
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
else lt.toast.error(data.error || 'Failed to delete');
|
||||
}).catch(err => lt.toast.error('Failed to delete'));
|
||||
});
|
||||
}
|
||||
|
||||
function editRecurring(id) {
|
||||
fetch('/api/manage_recurring.php?id=' + id)
|
||||
.then(r => r.json())
|
||||
lt.api.get('/api/manage_recurring.php?id=' + id)
|
||||
.then(data => {
|
||||
if (data.success && data.recurring) {
|
||||
const rt = data.recurring;
|
||||
@@ -342,15 +317,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
document.getElementById('priority').value = rt.priority || 4;
|
||||
document.getElementById('assigned_to').value = rt.assigned_to || '';
|
||||
document.getElementById('modalTitle').textContent = 'Edit Recurring Ticket';
|
||||
document.getElementById('recurringModal').style.display = 'flex';
|
||||
lt.modal.open('recurringModal');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load users for assignee dropdown
|
||||
function loadUsers() {
|
||||
fetch('/api/get_users.php')
|
||||
.then(r => r.json())
|
||||
lt.api.get('/api/get_users.php')
|
||||
.then(data => {
|
||||
if (data.success && data.users) {
|
||||
const select = document.getElementById('assigned_to');
|
||||
|
||||
@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Template Management - Admin</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">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
@@ -21,34 +24,35 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Templates</span>
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
<span class="admin-page-title">Admin: Templates</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>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
||||
<div class="ascii-frame-outer admin-container">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Ticket Template Management</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">Ticket Templates</h2>
|
||||
<button data-action="show-create-modal" class="btn">+ New Template</button>
|
||||
<div class="admin-header-row">
|
||||
<h2>Ticket Templates</h2>
|
||||
<button data-action="show-create-modal" class="btn">+ NEW TEMPLATE</button>
|
||||
</div>
|
||||
|
||||
<p style="color: var(--terminal-green-dim); margin-bottom: 1rem;">
|
||||
<p class="text-muted-green mb-1">
|
||||
Templates pre-fill ticket creation forms with standard content for common ticket types.
|
||||
</p>
|
||||
|
||||
<table style="width: 100%;">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Template Name</th>
|
||||
@@ -62,9 +66,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<tbody>
|
||||
<?php if (empty($templates)): ?>
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
||||
No templates defined. Create templates to speed up ticket creation.
|
||||
</td>
|
||||
<td colspan="6" class="empty-state">No templates defined. Create templates to speed up ticket creation.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($templates as $tpl): ?>
|
||||
@@ -74,46 +76,47 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<td><?php echo htmlspecialchars($tpl['type'] ?? 'Any'); ?></td>
|
||||
<td>P<?php echo $tpl['default_priority'] ?? '4'; ?></td>
|
||||
<td>
|
||||
<span style="color: <?php echo ($tpl['is_active'] ?? 1) ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
|
||||
<span class="<?php echo ($tpl['is_active'] ?? 1) ? 'text-open' : 'text-closed'; ?>">
|
||||
<?php echo ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive'; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button data-action="edit-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small">Edit</button>
|
||||
<button data-action="delete-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small btn-danger">Delete</button>
|
||||
<button data-action="edit-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small">EDIT</button>
|
||||
<button data-action="delete-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div class="settings-modal" id="templateModal" style="display: none;" data-action="close-modal-backdrop">
|
||||
<div class="settings-content" style="max-width: 800px; width: 90%;">
|
||||
<div class="settings-header">
|
||||
<h3 id="modalTitle">Create Template</h3>
|
||||
<button class="close-settings" data-action="close-modal">×</button>
|
||||
<div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||
<div class="lt-modal lt-modal-lg">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="modalTitle">Create Template</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<form id="templateForm">
|
||||
<input type="hidden" id="template_id" name="template_id">
|
||||
<div class="settings-body">
|
||||
<div class="lt-modal-body">
|
||||
<div class="setting-row">
|
||||
<label for="template_name">Template Name *</label>
|
||||
<input type="text" id="template_name" name="template_name" required style="width: 100%;">
|
||||
<input type="text" id="template_name" name="template_name" required>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="title_template">Title Template</label>
|
||||
<input type="text" id="title_template" name="title_template" style="width: 100%;" placeholder="Pre-filled title text">
|
||||
<input type="text" id="title_template" name="title_template" placeholder="Pre-filled title text">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="description_template">Description Template</label>
|
||||
<textarea id="description_template" name="description_template" rows="10" style="width: 100%; min-height: 200px;" placeholder="Pre-filled description content"></textarea>
|
||||
<textarea id="description_template" name="description_template" rows="10" placeholder="Pre-filled description content"></textarea>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;">
|
||||
<div class="setting-grid-3">
|
||||
<div class="setting-row setting-row-compact">
|
||||
<label for="category">Category</label>
|
||||
<select id="category" name="category">
|
||||
@@ -152,15 +155,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-footer">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
|
||||
<div class="lt-modal-footer">
|
||||
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
const templates = <?php echo json_encode($templates ?? []); ?>;
|
||||
|
||||
@@ -169,11 +171,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
document.getElementById('templateForm').reset();
|
||||
document.getElementById('template_id').value = '';
|
||||
document.getElementById('is_active').checked = true;
|
||||
document.getElementById('templateModal').style.display = 'flex';
|
||||
lt.modal.open('templateModal');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('templateModal').style.display = 'none';
|
||||
lt.modal.close('templateModal');
|
||||
}
|
||||
|
||||
// Event delegation for data-action handlers
|
||||
@@ -186,12 +188,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
case 'show-create-modal':
|
||||
showCreateModal();
|
||||
break;
|
||||
case 'close-modal':
|
||||
closeModal();
|
||||
break;
|
||||
case 'close-modal-backdrop':
|
||||
if (event.target === target) closeModal();
|
||||
break;
|
||||
case 'edit-template':
|
||||
editTemplate(target.dataset.id);
|
||||
break;
|
||||
@@ -206,12 +202,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
saveTemplate(e);
|
||||
});
|
||||
|
||||
// Close modal on ESC key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
|
||||
function saveTemplate(e) {
|
||||
e.preventDefault();
|
||||
@@ -226,25 +217,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
is_active: document.getElementById('is_active').checked ? 1 : 0
|
||||
};
|
||||
|
||||
const method = data.template_id ? 'PUT' : 'POST';
|
||||
const url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : '');
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
const apiCall = data.template_id ? lt.api.put(url, data) : lt.api.post(url, data);
|
||||
apiCall.then(result => {
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to save');
|
||||
lt.toast.error(result.error || 'Failed to save');
|
||||
}
|
||||
});
|
||||
}).catch(err => lt.toast.error('Failed to save'));
|
||||
}
|
||||
|
||||
function editTemplate(id) {
|
||||
@@ -260,18 +241,16 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
document.getElementById('priority').value = tpl.default_priority || 4;
|
||||
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
|
||||
document.getElementById('modalTitle').textContent = 'Edit Template';
|
||||
document.getElementById('templateModal').style.display = 'flex';
|
||||
lt.modal.open('templateModal');
|
||||
}
|
||||
|
||||
function deleteTemplate(id) {
|
||||
if (!confirm('Delete this template?')) return;
|
||||
fetch('/api/manage_templates.php?id=' + id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
showConfirmModal('Delete Template', 'Delete this template?', 'error', function() {
|
||||
lt.api.delete('/api/manage_templates.php?id=' + id)
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
else lt.toast.error(data.error || 'Failed to delete');
|
||||
}).catch(err => lt.toast.error('Failed to delete'));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?php
|
||||
// Admin view for user activity reports
|
||||
// Receives $userStats, $dateRange from controller
|
||||
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -9,24 +12,29 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Activity - Admin</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">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: User Activity</span>
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
<span class="admin-page-title">Admin: User Activity</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>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
||||
<div class="ascii-frame-outer admin-container">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
@@ -34,37 +42,38 @@
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<!-- Date Range Filter -->
|
||||
<form method="GET" style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: flex-end;">
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date From</label>
|
||||
<input type="date" name="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="setting-select">
|
||||
<form method="GET" class="admin-form-row">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="date_from">Date From</label>
|
||||
<input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="admin-input">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date To</label>
|
||||
<input type="date" name="date_to" value="<?php echo htmlspecialchars($dateRange['to'] ?? ''); ?>" class="setting-select">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="date_to">Date To</label>
|
||||
<input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($dateRange['to'] ?? ''); ?>" class="admin-input">
|
||||
</div>
|
||||
<div class="admin-form-actions">
|
||||
<button type="submit" class="btn">APPLY</button>
|
||||
<a href="?" class="btn btn-secondary">RESET</a>
|
||||
</div>
|
||||
<button type="submit" class="btn">Apply</button>
|
||||
<a href="?" class="btn btn-secondary">Reset</a>
|
||||
</form>
|
||||
|
||||
<!-- User Activity Table -->
|
||||
<table style="width: 100%;">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th style="text-align: center;">Tickets Created</th>
|
||||
<th style="text-align: center;">Tickets Resolved</th>
|
||||
<th style="text-align: center;">Comments Added</th>
|
||||
<th style="text-align: center;">Tickets Assigned</th>
|
||||
<th style="text-align: center;">Last Activity</th>
|
||||
<th class="text-center">Tickets Created</th>
|
||||
<th class="text-center">Tickets Resolved</th>
|
||||
<th class="text-center">Comments Added</th>
|
||||
<th class="text-center">Tickets Assigned</th>
|
||||
<th class="text-center">Last Activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($userStats)): ?>
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
||||
No user activity data available.
|
||||
</td>
|
||||
<td colspan="6" class="empty-state">No user activity data available.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($userStats as $user): ?>
|
||||
@@ -72,22 +81,22 @@
|
||||
<td>
|
||||
<strong><?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?></strong>
|
||||
<?php if ($user['is_admin']): ?>
|
||||
<span class="admin-badge" style="font-size: 0.7rem;">Admin</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
<span style="color: var(--terminal-green); font-weight: bold;"><?php echo $user['tickets_created'] ?? 0; ?></span>
|
||||
<td class="text-center">
|
||||
<span class="text-green fw-bold"><?php echo $user['tickets_created'] ?? 0; ?></span>
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
<span style="color: var(--status-open); font-weight: bold;"><?php echo $user['tickets_resolved'] ?? 0; ?></span>
|
||||
<td class="text-center">
|
||||
<span class="text-open fw-bold"><?php echo $user['tickets_resolved'] ?? 0; ?></span>
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
<span style="color: var(--terminal-cyan); font-weight: bold;"><?php echo $user['comments_added'] ?? 0; ?></span>
|
||||
<td class="text-center">
|
||||
<span class="text-cyan fw-bold"><?php echo $user['comments_added'] ?? 0; ?></span>
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
<span style="color: var(--terminal-amber); font-weight: bold;"><?php echo $user['tickets_assigned'] ?? 0; ?></span>
|
||||
<td class="text-center">
|
||||
<span class="text-amber fw-bold"><?php echo $user['tickets_assigned'] ?? 0; ?></span>
|
||||
</td>
|
||||
<td style="text-align: center; font-size: 0.9rem;">
|
||||
<td class="text-center text-sm">
|
||||
<?php echo $user['last_activity'] ? date('M d, Y H:i', strtotime($user['last_activity'])) : 'Never'; ?>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -95,41 +104,32 @@
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<?php if (!empty($userStats)): ?>
|
||||
<div style="margin-top: 2rem; padding: 1rem; border: 1px solid var(--terminal-green);">
|
||||
<h4 style="color: var(--terminal-amber); margin-bottom: 1rem;">Summary</h4>
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; text-align: center;">
|
||||
<div>
|
||||
<div style="font-size: 1.5rem; color: var(--terminal-green); font-weight: bold;">
|
||||
<?php echo array_sum(array_column($userStats, 'tickets_created')); ?>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Created</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 1.5rem; color: var(--status-open); font-weight: bold;">
|
||||
<?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Resolved</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 1.5rem; color: var(--terminal-cyan); font-weight: bold;">
|
||||
<?php echo array_sum(array_column($userStats, 'comments_added')); ?>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Comments</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 1.5rem; color: var(--terminal-amber); font-weight: bold;">
|
||||
<?php echo count($userStats); ?>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Active Users</div>
|
||||
</div>
|
||||
<div class="admin-stats-grid">
|
||||
<div>
|
||||
<div class="admin-stat-value text-green"><?php echo array_sum(array_column($userStats, 'tickets_created')); ?></div>
|
||||
<div class="admin-stat-label">Total Created</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="admin-stat-value text-open"><?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?></div>
|
||||
<div class="admin-stat-label">Total Resolved</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="admin-stat-value text-cyan"><?php echo array_sum(array_column($userStats, 'comments_added')); ?></div>
|
||||
<div class="admin-stat-label">Total Comments</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="admin-stat-value text-amber"><?php echo count($userStats); ?></div>
|
||||
<div class="admin-stat-label">Active Users</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Workflow Designer - Admin</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">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
@@ -21,47 +24,47 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Workflow Designer</span>
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
<span class="admin-page-title">Admin: Workflow Designer</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>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
||||
<div class="ascii-frame-outer admin-container">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Status Workflow Designer</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">Status Transitions</h2>
|
||||
<button data-action="show-create-modal" class="btn">+ New Transition</button>
|
||||
<div class="admin-header-row">
|
||||
<h2>Status Transitions</h2>
|
||||
<button data-action="show-create-modal" class="btn">+ NEW TRANSITION</button>
|
||||
</div>
|
||||
|
||||
<p style="color: var(--terminal-green-dim); margin-bottom: 1rem;">
|
||||
<p class="text-muted-green mb-1">
|
||||
Define which status transitions are allowed. This controls what options appear in the status dropdown.
|
||||
</p>
|
||||
|
||||
<!-- Visual Workflow Diagram -->
|
||||
<div style="margin-bottom: 2rem; padding: 1rem; border: 1px solid var(--terminal-green); background: var(--bg-secondary);">
|
||||
<h4 style="color: var(--terminal-amber); margin-bottom: 1rem;">Workflow Diagram</h4>
|
||||
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap;">
|
||||
<div class="workflow-diagram">
|
||||
<h4 class="admin-section-title">Workflow Diagram</h4>
|
||||
<div class="workflow-diagram-nodes">
|
||||
<?php
|
||||
$statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
foreach ($statuses as $status):
|
||||
$statusClass = 'status-' . str_replace(' ', '-', strtolower($status));
|
||||
?>
|
||||
<div style="text-align: center;">
|
||||
<div class="<?php echo $statusClass; ?>" style="padding: 0.5rem 1rem; display: inline-block;">
|
||||
<div class="workflow-diagram-node">
|
||||
<div class="<?php echo $statusClass; ?>">
|
||||
<?php echo $status; ?>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim); margin-top: 0.5rem;">
|
||||
<div class="text-muted-green workflow-diagram-node-label">
|
||||
<?php
|
||||
$toCount = 0;
|
||||
if (isset($workflows)) {
|
||||
@@ -78,7 +81,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
</div>
|
||||
|
||||
<!-- Transitions Table -->
|
||||
<table style="width: 100%;">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>From Status</th>
|
||||
@@ -93,9 +97,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<tbody>
|
||||
<?php if (empty($workflows)): ?>
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
||||
No transitions defined. Add transitions to enable status changes.
|
||||
</td>
|
||||
<td colspan="7" class="empty-state">No transitions defined. Add transitions to enable status changes.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($workflows as $wf): ?>
|
||||
@@ -105,42 +107,43 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<?php echo htmlspecialchars($wf['from_status']); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td style="text-align: center; color: var(--terminal-amber);">→</td>
|
||||
<td class="text-amber text-center">→</td>
|
||||
<td>
|
||||
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['to_status'])); ?>">
|
||||
<?php echo htmlspecialchars($wf['to_status']); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td style="text-align: center;"><?php echo $wf['requires_comment'] ? '✓' : '−'; ?></td>
|
||||
<td style="text-align: center;"><?php echo $wf['requires_admin'] ? '✓' : '−'; ?></td>
|
||||
<td style="text-align: center;">
|
||||
<span style="color: <?php echo $wf['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
|
||||
<td class="text-center"><?php echo $wf['requires_comment'] ? '✓' : '−'; ?></td>
|
||||
<td class="text-center"><?php echo $wf['requires_admin'] ? '✓' : '−'; ?></td>
|
||||
<td class="text-center">
|
||||
<span class="<?php echo $wf['is_active'] ? 'text-open' : 'text-closed'; ?>">
|
||||
<?php echo $wf['is_active'] ? '✓' : '✗'; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button data-action="edit-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small">Edit</button>
|
||||
<button data-action="delete-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small btn-danger">Delete</button>
|
||||
<button data-action="edit-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small">EDIT</button>
|
||||
<button data-action="delete-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div class="settings-modal" id="workflowModal" style="display: none;" data-action="close-modal-backdrop">
|
||||
<div class="settings-content" style="max-width: 450px;">
|
||||
<div class="settings-header">
|
||||
<h3 id="modalTitle">Create Transition</h3>
|
||||
<button class="close-settings" data-action="close-modal">×</button>
|
||||
<div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||
<div class="lt-modal lt-modal-sm">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="modalTitle">Create Transition</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<form id="workflowForm">
|
||||
<input type="hidden" id="transition_id" name="transition_id">
|
||||
<div class="settings-body">
|
||||
<div class="lt-modal-body">
|
||||
<div class="setting-row">
|
||||
<label for="from_status">From Status *</label>
|
||||
<select id="from_status" name="from_status" required>
|
||||
@@ -169,15 +172,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-footer">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
|
||||
<div class="lt-modal-footer">
|
||||
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
const workflows = <?php echo json_encode($workflows ?? []); ?>;
|
||||
|
||||
@@ -186,11 +188,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
document.getElementById('workflowForm').reset();
|
||||
document.getElementById('transition_id').value = '';
|
||||
document.getElementById('is_active').checked = true;
|
||||
document.getElementById('workflowModal').style.display = 'flex';
|
||||
lt.modal.open('workflowModal');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('workflowModal').style.display = 'none';
|
||||
lt.modal.close('workflowModal');
|
||||
}
|
||||
|
||||
// Event delegation for data-action handlers
|
||||
@@ -203,12 +205,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
case 'show-create-modal':
|
||||
showCreateModal();
|
||||
break;
|
||||
case 'close-modal':
|
||||
closeModal();
|
||||
break;
|
||||
case 'close-modal-backdrop':
|
||||
if (event.target === target) closeModal();
|
||||
break;
|
||||
case 'edit-transition':
|
||||
editTransition(target.dataset.id);
|
||||
break;
|
||||
@@ -223,12 +219,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
saveTransition(e);
|
||||
});
|
||||
|
||||
// Close modal on ESC key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
|
||||
function saveTransition(e) {
|
||||
e.preventDefault();
|
||||
@@ -241,25 +232,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
is_active: document.getElementById('is_active').checked ? 1 : 0
|
||||
};
|
||||
|
||||
const method = data.transition_id ? 'PUT' : 'POST';
|
||||
const url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
const apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
|
||||
apiCall.then(result => {
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to save');
|
||||
lt.toast.error(result.error || 'Failed to save');
|
||||
}
|
||||
});
|
||||
}).catch(err => lt.toast.error('Failed to save'));
|
||||
}
|
||||
|
||||
function editTransition(id) {
|
||||
@@ -273,18 +254,16 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
document.getElementById('requires_admin').checked = wf.requires_admin == 1;
|
||||
document.getElementById('is_active').checked = wf.is_active == 1;
|
||||
document.getElementById('modalTitle').textContent = 'Edit Transition';
|
||||
document.getElementById('workflowModal').style.display = 'flex';
|
||||
lt.modal.open('workflowModal');
|
||||
}
|
||||
|
||||
function deleteTransition(id) {
|
||||
if (!confirm('Delete this status transition?')) return;
|
||||
fetch('/api/manage_workflows.php?id=' + id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
showConfirmModal('Delete Transition', 'Delete this status transition?', 'error', function() {
|
||||
lt.api.delete('/api/manage_workflows.php?id=' + id)
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
else lt.toast.error(data.error || 'Failed to delete');
|
||||
}).catch(err => lt.toast.error('Failed to delete'));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user