Compare commits

..

31 Commits

Author SHA1 Message Date
ce95e555d5 CSS class migrations: admin views and boot overlay fade-out
- Replace style.display with .is-hidden classList in ApiKeysView, CustomFieldsView, RecurringTicketsView
- Convert boot overlay fade-out from style.opacity to .boot-overlay--fade-out CSS class
- Add .boot-overlay--fade-out rule to dashboard.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:20:55 -04:00
f45ec9b0f7 CSS class migrations in CreateTicketView: duplicate warning, visibility groups
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:18:16 -04:00
5a41ebf180 Convert ticket preview popup visibility to use .is-hidden CSS class
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:16:49 -04:00
e35401d54e CSS class migrations for ticket page: tabs, visibility, markdown preview, uploads
- Switch tab show/hide from style.display to .tab-content.active CSS class
- Convert visibilityGroupsField, markdownPreview, uploadProgress to use .is-hidden class
- Replace comment text div style.display with classList.add/remove('is-hidden')
- Add .is-hidden utility class to ticket.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:13:55 -04:00
913e294f9d CSS class migrations: stat-card cursor, view toggle, bulk actions visibility
- Replace stat-card cursor:pointer inline style with CSS rule
- Convert view toggle (table/card) to use .is-hidden CSS class
- Convert bulk-actions and export-dropdown to use .is-visible class
- Add .is-hidden/.is-visible utility rules to dashboard.css
- Remove duplicate lt.keys.initDefaults() call from dashboard.js
- Remove redundant setTimeout from view mode restore
- Add lt.keys.initDefaults() to dashboard.js (was missing entirely)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:08:28 -04:00
28aa9e33ea Fix XSS: escape table data and sanitize sort/pagination URL params
- htmlspecialchars() on category, type, status in table rows
- htmlspecialchars() on data-status attributes in quick-action buttons
- Restrict $currentDir to 'asc'|'desc' to prevent class injection
- htmlspecialchars() on all http_build_query URLs in pagination and sort headers
- htmlspecialchars() on AuditLogView pagination URLs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:40:51 -04:00
31aa7d1b81 Fix JS SyntaxError breaking tabs, textarea scrolling, and XSS escaping
Bug fixes:
- ticket.js: Remove duplicate const textarea declaration inside showMentionSuggestions()
  (was redeclaring a parameter, causing SyntaxError that broke all tab switching)
- ticket.css: Add overflow:hidden + resize:none to disabled textarea so description
  shows full height without internal scrollbar (page scrolls instead)
- ticket.js: Trigger height recalculation when entering edit mode on description

XSS/escaping fixes:
- TicketView.php: htmlspecialchars() on description textarea content (closes </textarea> injection risk)
- TicketView.php: htmlspecialchars() on ticket status and workflow transition status strings
- DashboardView.php: htmlspecialchars() on $cat/$type in input value= attributes
- RecurringTicketsView.php: htmlspecialchars() on composed schedule string

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:34:55 -04:00
7695c6134c Accessibility pass: ARIA roles, label associations, CSS class migrations
- Add role=dialog/aria-modal/aria-labelledby to all 12 modal overlays (JS + PHP)
- Add aria-label="Close" to all 14 modal close buttons
- Add full ARIA combobox pattern to @mention autocomplete (listbox, option, aria-selected, aria-expanded)
- Add for= attributes to admin filter form labels (AuditLog, UserActivity, ApiKeys)
- Remove dead closeOnAdvancedSearchBackdropClick() from advanced-search.js

CSS/JS style cleanup:
- Move .ascii-banner static styles from JS inline to CSS class; add .ascii-banner--glow
- Add .ascii-banner-cursor, .loading-overlay--hiding, .has-overlay, tr[data-clickable]
- Add .animate-fadein/.animate-fadeout/.comment--deleting to ticket.css
- Add .lt-toast--hiding to base.css; remove opacity/transition inline JS
- Remove redundant cursor:pointer JS (already in th{} CSS rule)
- Remove trailing space in lt-select class attributes

Bug fixes:
- base.js: boot overlay opacity inline style was overriding .fade-out class opacity via
  specificity (1000 vs 20), preventing the fade-out animation — removed
- ascii-banner.js: cursor used blink-caret (border-color only) instead of blink-cursor
  (opacity-based), so the █ cursor never actually blinked — fixed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:29:58 -04:00
11f75fd823 Migrate all raw fetch() calls to lt.api, fix CSS fallback values
- Replace all 23 raw fetch() calls in dashboard.js and ticket.js with
  lt.api.get/post/delete — removes manual CSRF header injection,
  manual JSON parsing boilerplate, and response.ok checks throughout
- dashboard.js: 10 calls (inline save x2, template GET, 5x bulk ops,
  quick-status, quick-assign)
- ticket.js: 13 calls (main save, add/update/delete comment x3, reply,
  assign, metadata update, status change, deps GET/POST/DELETE,
  attachments GET, delete attachment)
- Remove stale csrf_token from deleteAttachment body (lt.api sends the
  X-CSRF-Token header automatically)
- Fix CSS variable fallbacks in ticket.css: replace
  var(--text-primary, #f7fafc) and var(--bg-secondary, #1a202c)
  with plain var(--text-primary) and var(--bg-secondary)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:27:46 -04:00
e179709fc3 Add lt.autoRefresh, fix showToast in admin, clean up inline styles
- Replace all 8 showToast() calls in ApiKeysView.php with lt.toast.*
  — all toast calls in the codebase now use lt.toast directly
- Add .duplicate-list, .duplicate-meta, .duplicate-hint CSS classes to
  dashboard.css; replace inline styles in duplicate detection JS with them
- Add dashboardAutoRefresh() using lt.autoRefresh — reloads page every
  5 minutes, skipping if a modal is open or user is typing in an input
- Add REFRESH button to dashboard header that triggers lt.autoRefresh.now()
  for immediate manual refresh with timer restart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:16:18 -04:00
b03a9cfc8c Extract hardcoded rgba colors and inline styles to CSS classes
- Add .inline-error and .inline-warning utility classes to dashboard.css
  with correctly-matched terminal palette rgba values (replaces off-palette
  rgba(231,76,60,0.1) and rgba(241,196,15,0.1))
- Add .key-generated-alert class for the new API key display frame
- Add base .dependency-item, .dependency-group h4, .dependency-item a,
  .dependency-title, .btn-small overrides to ticket.css
- Remove all inline styles from the dependency list template in ticket.js
  — layout, colors, and sizing now come from CSS classes
- Update CreateTicketView.php and ApiKeysView.php to use the new classes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:08:52 -04:00
d44a530018 Extend lt.time.ago() to ticket view, replace showToast with lt.toast
- Add data-ts attributes to TicketView.php: ticket created/updated
  header, comment dates (inner span to preserve edited indicator),
  and all activity timeline dates
- Add initRelativeTimes() to ticket.js using lt.time.ago(); runs on
  DOMContentLoaded and every 60s to keep relative times current
- Attachment dates now use lt.time.ago() with full date in title attr
  and ts-cell span for periodic refresh
- Replace all 11 showToast() calls in ticket.js with lt.toast.* directly,
  removing reliance on the backwards-compat shim for these paths
- Add span.ts-cell and td.ts-cell CSS to both dashboard.css and ticket.css:
  dotted underline + cursor:help signals the title tooltip is available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:03:34 -04:00
3c3b9d0a61 Integrate lt.time.ago() for dashboard timestamps, update README
- Add data-ts attributes to table and card view date cells so JS can
  convert them to relative time ("2h ago") while keeping the full date
  in the title attribute for hover tooltips
- Add initRelativeTimes() in dashboard.js using lt.time.ago(); runs on
  DOMContentLoaded and refreshes every 60s so times stay current
- Fix table sort for date columns to read data-ts attribute instead of
  text content (which is now relative and not sortable as a date)
- Update README: add base.css/base.js/utils.js to project structure,
  fix ascii-banner.js description, expand keyboard shortcuts table,
  add developer notes for lt.time and boot sequence behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:52:59 -04:00
1046537429 Move ASCII banner into boot sequence, fix remaining UI issues
- Remove collapsible ASCII banner from dashboard (was cluttering the UI)
- Show ASCII banner in the boot overlay on first session visit, above
  the boot messages, with a 400ms pause before messages begin
- Add scroll fade indicator (green-tinted gradient edges) to .table-wrapper
  so users can see when the table is horizontally scrollable
- Fix null guards for tab switcher in ticket.js (tabEl, activeBtn)
- Fix Reset → RESET uppercase in AuditLogView and UserActivityView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:41:57 -04:00
d8220da1e0 Make dashboard table horizontally scrollable at smaller screen widths
- Set .table-wrapper to overflow-x: auto with touch scrolling support
- Add min-width: 900px to table to trigger scroll before columns collapse
- Set .ascii-frame-outer overflow-x: visible to avoid clipping conflict

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:35:45 -04:00
021c01b3d4 Polish: uppercase all admin view button text
- AuditLogView.php: FILTER, RESET
- UserActivityView.php: APPLY, RESET
- ApiKeysView.php: GENERATE KEY, COPY, REVOKE
- WorkflowDesignerView.php: + NEW TRANSITION, EDIT, DELETE, SAVE, CANCEL
- CustomFieldsView.php: + NEW FIELD, EDIT, DELETE, SAVE, CANCEL
- TemplatesView.php: + NEW TEMPLATE, EDIT, DELETE, SAVE, CANCEL
- RecurringTicketsView.php: + NEW RECURRING TICKET, EDIT, DISABLE/ENABLE, DELETE, SAVE, CANCEL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:27:18 -04:00
22cab10d5d Polish: uppercase remaining modal and pagination button text
- DashboardView.php: settings modal SAVE PREFERENCES/CANCEL, advanced search SEARCH/RESET/CANCEL
- DashboardView.php: pagination prev/next add [ « ] and [ » ] brackets
- TicketView.php: settings modal SAVE PREFERENCES/CANCEL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:19:13 -04:00
f0d7b9aa61 Polish: uppercase all remaining mixed-case button text
- DashboardView.php: APPLY FILTERS, CLEAR ALL, SEARCH, CHANGE STATUS, ASSIGN, PRIORITY, CLEAR, EXPORT SELECTED
- CreateTicketView.php: CREATE TICKET, CANCEL
- ticket.js: SAVE, CANCEL, REMOVE, REPLY in dynamically-generated HTML templates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:14:41 -04:00
3493ed78f8 Polish: uppercase button text, ASCII-safe stat icons and boot sequence
- TicketView.php: 'Edit Ticket' → 'EDIT TICKET'
- DashboardView.php: '+ New Ticket' → '+ NEW TICKET'
- DashboardView.php: stat-icon [ ✓ ] → [ OK ] (ASCII-safe)
- DashboardView.php: boot sequence '> SYSTEM READY ✓' → '> SYSTEM READY [OK]'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:10:38 -04:00
90c5b3ff71 UI/UX polish: terminal design system alignment pass
Views:
- DashboardView.php: remove hardcoded [ ] from admin-badge button (CSS adds them)
- DashboardView.php: view toggle ≡/▦ → [ = ]/[ # ] (view-btn suppresses auto-brackets)
- DashboardView.php: clear-search ✗ → [ X ] (plain text, no auto-brackets on <a>)
- DashboardView.php: remove ↓ arrow emoji from export button text
- TicketView.php: tab labels → UPPERCASE (tab-btn CSS adds [ ] around text)
- TicketView.php: Edit Ticket/Clone/Add Comment/Add → title-case → UPPERCASE
- TicketView.php: reply button ↩ → [ << ] (comment-action-btn has no auto-brackets)

JavaScript:
- dashboard.js: modal/action button text all → UPPERCASE (CONFIRM/CANCEL/SAVE/ASSIGN/UPDATE/DELETE PERMANENTLY)
- dashboard.js: null guard in loadTemplate(), toggleSelectAll()
- ticket.js: null guards in addDependency(), handleFileUpload()

CSS:
- dashboard.css: z-index 1001/1002 magic numbers → var(--z-modal)/var(--z-popover)
- ticket.css: status-select hover/focus border rgba(white) → terminal palette

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:50:59 -04:00
84bea80abd Fix PHP parse error and CSS/JS follow-on fixes
- DashboardView.php: fix PHP parse error on line 456/472/473/474 caused by
  escaped double-quotes {$row[\"key\"]} inside double-quoted echo strings;
  replaced with safe string concatenation . $row['key'] .
- ticket.css: fix status-select hover/focus border rgba(white) → terminal palette
- ticket.js: add null guards to addComment, togglePreview, updatePreview,
  toggleMarkdownMode, and addDependency element lookups

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:44:08 -04:00
2f9af856dc Fix design system violations: replace off-brand colors with terminal palette
- dashboard.css: replace all hardcoded Tailwind hex colors (#2d3748, #1a202c,
  #e2e8f0, #4a5568, #007cba, #3b82f6 etc.) in dark-mode sections and component
  styles with terminal CSS variables (--bg-*, --text-*, --border-color,
  --terminal-green/amber)
- dashboard.css: fix card-priority colors white/black → var(--bg-primary)
- dashboard.css: fix card-assignee border-radius: 50% → 0 (no circles rule)
- dashboard.css: fix mobile bottom-sheet border-radius: 12px → 0
- dashboard.css: fix search-box focus border (#007cba → var(--terminal-green))
- dashboard.css: fix save-filter button blue (#3b82f6) → terminal green
- dashboard.css: fix search-results-info blue highlight → terminal green
- dashboard.css: fix btn-bulk/btn-secondary dark-mode bootstrap colors → terminal
- ticket.css: replace comprehensive dark-mode Tailwind hex block with CSS vars
- ticket.css: fix status-select white/black text → var(--bg-primary)
- ticket.css: fix status-select.status-resolved hardcoded #28a745 → var(--status-open)
- ticket.css: fix timeline dark-mode hardcoded colors → CSS vars
- ticket.css: fix .slider:before background white → var(--bg-primary)
- ticket.css: fix .btn-danger:hover color white → var(--bg-primary)
- ticket.css: fix visibility-groups-list label border-radius: 4px → 0
- ticket.css: add will-change: opacity to age-warning/age-critical animations
- views: bump CSS version strings to v=20260319c
- views/DashboardView.php: add aria-labels to card view quick action buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:37:19 -04:00
27075a62ee Fix bracket buttons rendering below text + UI/security improvements
CSS fixes:
- Fix [ ] brackets appearing below button text by replacing display:inline-flex
  with display:inline-block + white-space:nowrap on .btn — removes cross-browser
  flex pseudo-element inconsistency as root cause
- Remove conflicting .btn::before ripple block (position:absolute was overriding
  bracket content positioning)
- Remove overflow:hidden from .btn which was clipping bracket content
- Fix body::after duplicate rule causing GPU layer blink (second position:fixed
  rule re-created compositor layer, overriding display:none suppression)
- Replace all transition:all with scoped property transitions in dashboard.css,
  ticket.css, base.css (prevents full CSS property evaluation on every hover)
- Convert pulse-warning/pulse-critical keyframes from box-shadow to opacity
  animation (GPU-composited, eliminates CPU repaints at 60fps)
- Fix mobile *::before/*::after blanket content:none rule — now targets only
  decorative frame glyphs, preserving button brackets and status indicators
- Remove --terminal-green-dim override that broke .lt-btn hover backgrounds

JS fixes:
- Fix all lt.lt.toast.* double-prefix instances in dashboard.js
- Add null guard before .appendChild() on bulkAssignUser select
- Replace all remaining emoji with terminal bracket notation (dashboard.js,
  ticket.js, markdown.js)
- Migrate all toast.*() shim calls to lt.toast.* across all JS files

View fixes:
- Remove hardcoded [ ] brackets from .btn buttons (CSS now adds them)
- Replace all emoji with terminal bracket notation in all views and admin views
- Add missing CSP nonces to AuditLogView.php and UserActivityView.php script tags
- Bump CSS version strings to ?v=20260319b for cache busting

Security fixes:
- update_ticket.php: add authorization check (non-admins can only edit their own
  or assigned tickets)
- add_comment.php: validate and cast ticket_id to integer with 400 response
- clone_ticket.php: fix unconditional session_start(), add ticket ID validation,
  add internal ticket access check
- bulk_operation.php: add HTTP 401/403 status codes on auth failures
- upload_attachment.php: fix missing $conn arg in AttachmentModel constructor
- assign_ticket.php: add ticket existence check and permission verification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:20:43 -04:00
dd8833ee2f Restore visual effects using GPU-safe techniques (no repaint triggers)
Rules: transform/opacity = GPU composited (fine). box-shadow/text-shadow on
hover = CPU repaint (removed). Static box-shadow/text-shadow = painted once (fine).

- Buttons (.btn, .btn-base, button, .btn-primary): add will-change:transform
  for pre-promotion, add transform:translateY(-1px) on hover (GPU, no repaint),
  scope transition to include transform, remove box-shadow/text-shadow from hover
- Stat cards: add will-change:transform, add transform:translateY(-2px) on hover
- Priority badges: replace filter:blur(6px) ::after pseudo-element (permanent GPU
  layer per badge, ~20 on screen at once) with static box-shadow:0 0 6px currentColor
  on the badge itself — painted once, never changes, zero compositor overhead
- Links: replace opacity-transition ::after underline (lazy GPU layer creation on
  hover) with text-decoration:underline on hover (pure CPU paint, no GPU layer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 19:28:28 -04:00
ab3e77a9ba Fix blink root cause: eliminate position:fixed GPU compositing layers
Chrome promotes ALL position:fixed elements to GPU compositing layers for scroll
performance, regardless of whether they have animations. The body::before scanline
overlay (position:fixed, z-index:9999, full-viewport) and body::after watermark
(position:fixed) were both on GPU layers. Every CPU repaint from any hover state
change required a compositor re-blend pass → one-frame blink at compositor sync.

Fixes:
- Move scanlines from body::before (position:fixed) into body { background-image }
  — same visual, no separate element, no GPU layer promotion
- Set body::before { display:none } and body::after { display:none } in both
  dashboard.css and base.css
- Remove animation:matrix-rain from .stat-card:hover::before — background-position
  animation is not GPU-composited, caused CPU repaints every frame while hovered
  plus GPU texture uploads when animation started/stopped on cursor enter/exit
- Scope a { transition: all } → transition: color in base.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:23:30 -04:00
68ff89b48c Fix persistent blink: scanline animation still active via base.css cascade
Root cause: removing 'animation' from dashboard.css body::before did NOT disable
the scanline — it just stopped overriding base.css which still had
'animation: scanline 8s linear infinite'. CSS cascade means the base.css value
remained active. Fixed by setting 'animation: none' explicitly in dashboard.css.

Also fix base.css (used by all pages including ticket page):
- Set body::before animation: none (removes GPU compositing layer from scanline)
- Change corner-pulse/subtle-pulse/pulse-glow/pulse-red keyframes from text-shadow
  and box-shadow animations to opacity (GPU composited, zero CPU repaint overhead)
- Change exec-running-pulse from box-shadow to opacity
- Remove box-shadow from .lt-table tr:hover, .lt-card:hover, .lt-stat-card:hover
- Remove text-shadow/box-shadow/transform from .lt-btn:hover and variants
- Remove text-shadow from a:hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:12:44 -04:00
328c103460 Fix ascii-frame-outer blink: eliminate all repaint-causing hover effects
- Change pulse-glow keyframes from text-shadow animation to opacity (GPU composited,
  eliminates 60fps CPU repaint that was the likely root cause of the persistent blink)
- Remove box-shadow from .quick-action-btn:hover; scope transition: all → background/color
- Remove box-shadow + background gradient + transform:translateY from .stat-card:hover;
  scope transition: all → border-color only
- Remove .stat-card::after transition and hover background change
- Remove duplicate .stat-card:hover transform:translateY block
- Remove box-shadow from .clear-search-btn:hover; scope transition: all → background/color
- Remove text-shadow from th transition (th:hover never changes text-shadow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:06:39 -04:00
21ef9154e9 Fix ascii-frame-outer blink: remove scanline GPU layer and remaining repaint triggers
- Remove body::before scanline animation (transform: translateY promoted it to a
  GPU compositing layer; CPU repaints from hover states required compositor re-blend,
  causing one-frame blink at compositor sync boundary)
- Remove text-shadow and transform: translateY(-2px) from .btn-primary:hover/.create-ticket:hover
- Scope .btn-primary transition from 'all' to specific composited properties
- Remove box-shadow: inset from .banner-toggle:hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:57:47 -04:00
4ecd72bc04 Strip all box-shadow/text-shadow from hover states inside ascii-frame-outer
These non-composited properties force CPU repaints of the surrounding
paint region (the section itself) every time hover enters or exits.
With the fixed overlay (body::before scanline), each such repaint
requires the compositor to re-blend the layer, visible as a blink.

Removed from dashboard.css:
- btn/button/btn-base:hover: box-shadow + text-shadow
- th:hover: text-shadow
- ticket-link:hover: text-shadow
- pagination button:hover: box-shadow + transform + transition:all
- ticket-card-row:hover: box-shadow + transition:all -> background only
- .btn ripple rule: transition:all -> specific properties
- ascii-frame-outer: removed will-change/translateZ (GPU upload worse)

Removed from ticket.css:
- metadata-select:hover: box-shadow; transition:all -> border-color
- comment:hover: box-shadow
- btn:hover: box-shadow
- mention:hover: text-shadow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:50:06 -04:00
368ad9b48e Promote ascii-frame-outer to GPU layer to stop hover blink
The body::before scanline overlay (position:fixed, z-index:9999) requires
the compositor to re-blend over the section every time a CPU repaint
happens inside it. Hover state entry/exit triggers these repaints, causing
a visible blink as the compositor flushes.

Fixes:
- Add will-change:transform + transform:translateZ(0) to ascii-frame-outer
  to promote it to its own GPU compositing layer, isolating its repaints
  from the scanline compositing pass
- Convert corner-pulse and subtle-pulse from text-shadow (CPU repaint)
  to opacity (GPU composited) to eliminate continuous repaint pressure
  inside the section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:35:38 -04:00
3497c4cb47 Fix ascii-frame-outer blink: remove compositor layer thrashing on hover
Four root causes removed:
- body { transition: all } — forced browser to check all CSS properties
  on every hover event across the entire page
- a:not(.btn)::after underline: width+box-shadow transition replaced with
  opacity transition — width repaints paint layer, box-shadow forced parent
  section repaint; opacity is GPU-composited and doesn't repaint ancestors
- .ticket-link:hover { transform: translateX } — created/destroyed GPU
  compositor layer on every ticket ID hover; removed, scoped transition
  to specific non-layout properties
- .btn:hover { transform: translateY } in ticket.css — same layer issue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:25:18 -04:00
28 changed files with 1948 additions and 1756 deletions

View File

@@ -126,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 |
@@ -242,17 +247,20 @@ tinker_tickets/
│ └── 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
│ │ ├── 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
│ │ ├── 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 # Toast notifications
│ │ ── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
│ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
│ └── images/
│ └── favicon.png
├── config/
@@ -387,6 +395,14 @@ Key conventions and gotchas for working with this codebase:
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

View File

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

View File

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

View File

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

View File

@@ -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'],

View File

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

View File

@@ -155,7 +155,7 @@ if (empty($originalFilename)) {
// Save to database
try {
$attachmentModel = new AttachmentModel();
$attachmentModel = new AttachmentModel($conn);
$attachmentId = $attachmentModel->addAttachment(
$ticketId,
$uniqueFilename,

View File

@@ -128,9 +128,9 @@
--header-height: 58px;
--container-max: 1600px;
/* --- Transitions --- */
--transition-fast: all 0.15s ease;
--transition-default: all 0.3s ease;
/* --- Transitions — scoped to GPU-safe properties (no box-shadow/filter) --- */
--transition-fast: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
--transition-default: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
/* --- Z-index ladder --- */
--z-base: 1;
@@ -171,11 +171,10 @@ body {
a {
color: var(--terminal-green);
text-decoration: none;
transition: var(--transition-fast);
transition: color 0.15s ease;
}
a:hover {
color: var(--terminal-amber);
text-shadow: var(--glow-amber);
}
ul, ol { list-style: none; }
@@ -186,37 +185,21 @@ img, svg { display: block; max-width: 100%; }
03. CRT & TERMINAL EFFECTS
---------------------------------------------------------------- */
/* Horizontal scanline overlay — fixed over the entire viewport */
body::before {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
/* Scanlines baked into body background — position:fixed overlay removed because
Chrome promotes all position:fixed elements to GPU compositing layers, causing
a compositor re-blend blink on every CPU repaint triggered by hover states. */
body {
background-image: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15) 0px,
rgba(0, 0, 0, 0.15) 1px,
transparent 1px,
transparent 2px
);
pointer-events: none;
z-index: var(--z-overlay);
animation: scanline 8s linear infinite;
}
/* Binary data-stream watermark — bottom-right corner */
body::after {
content: '10101010';
position: fixed;
bottom: 10px;
right: 14px;
font-family: var(--font-mono);
font-size: 0.55rem;
color: var(--terminal-green);
opacity: 0.07;
pointer-events: none;
letter-spacing: 2px;
animation: data-stream 3s steps(1) infinite;
}
body::before { display: none; }
/* body::after binary watermark also suppressed — was position:fixed (GPU layer) */
body::after { display: none; }
/* ----------------------------------------------------------------
04. TYPOGRAPHY
@@ -650,7 +633,6 @@ pre {
}
.lt-card:hover {
border-color: var(--terminal-green);
box-shadow: var(--box-glow-green);
}
.lt-card-title {
@@ -701,13 +683,9 @@ pre {
background: var(--terminal-green-dim);
color: var(--terminal-amber);
border-color: var(--terminal-amber);
text-shadow: var(--glow-amber-intense);
box-shadow: var(--box-glow-amber);
transform: translateY(-2px);
}
.lt-btn:active {
transform: translateY(0);
box-shadow: var(--box-glow-green);
opacity: 0.85;
}
.lt-btn:disabled {
opacity: 0.4;
@@ -725,7 +703,6 @@ pre {
.lt-btn-primary::before { content: '> '; }
.lt-btn-primary:hover {
background: var(--terminal-amber-dim);
box-shadow: var(--box-glow-amber);
}
/* Red (destructive / danger) */
@@ -737,8 +714,6 @@ pre {
background: var(--terminal-red-dim);
color: var(--terminal-red);
border-color: var(--terminal-red);
text-shadow: var(--glow-red);
box-shadow: var(--box-glow-red);
}
/* Small variant */
@@ -899,7 +874,6 @@ pre {
}
.lt-table tbody tr:hover {
background: rgba(0, 255, 65, 0.06);
box-shadow: inset 0 0 20px rgba(0, 255, 65, 0.05);
}
/* Data table — compact, row-only separators, good for dense data */
@@ -948,7 +922,8 @@ pre {
.lt-table-wrap { overflow-x: auto; border: 2px solid var(--terminal-green); }
/* Sortable column header */
.lt-table th[data-sort] { cursor: pointer; }
.lt-table th[data-sort],
th[data-sort-key] { cursor: pointer; }
.lt-table th[data-sort]:hover { color: var(--terminal-green); text-shadow: var(--glow-green); }
.lt-table th[data-sort="asc"]::after { content: ' ▲'; color: var(--terminal-green); }
.lt-table th[data-sort="desc"]::after { content: ' ▼'; color: var(--terminal-green); }
@@ -1203,6 +1178,7 @@ pre {
.lt-toast-close::before,
.lt-toast-close::after { content: ''; }
.lt-toast-close:hover { opacity: 1; transform: none; }
.lt-toast--hiding { opacity: 0; transition: opacity 0.3s ease; }
/* ----------------------------------------------------------------
15. TAB NAVIGATION
@@ -1357,8 +1333,6 @@ pre {
}
.lt-stat-card:hover {
border-color: var(--terminal-amber);
box-shadow: var(--box-glow-amber);
transform: translateY(-2px);
}
.lt-stat-card.active {
background: var(--terminal-amber-dim);
@@ -1560,23 +1534,23 @@ pre {
}
@keyframes corner-pulse {
0%, 100% { text-shadow: var(--glow-green); }
50% { text-shadow: var(--glow-green-intense); }
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
@keyframes subtle-pulse {
0%, 100% { text-shadow: var(--glow-amber); }
50% { text-shadow: var(--glow-amber-intense); }
0%, 100% { opacity: 0.75; }
50% { opacity: 1; }
}
@keyframes pulse-glow {
0%, 100% { text-shadow: 0 0 5px currentColor, 0 0 10px currentColor; }
50% { text-shadow: 0 0 10px currentColor, 0 0 20px currentColor, 0 0 30px currentColor; }
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
@keyframes pulse-red {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 68, 68, 0.5); }
50% { box-shadow: 0 0 6px 3px rgba(255, 68, 68, 0.2); }
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
@keyframes focus-pulse {
@@ -1615,8 +1589,8 @@ pre {
/* Item pulse for actively running tasks */
@keyframes exec-running-pulse {
0%, 100% { border-color: var(--terminal-green); }
50% { border-color: var(--status-running); box-shadow: 0 0 8px rgba(255, 193, 7, 0.35); }
0%, 100% { border-color: var(--terminal-green); opacity: 0.7; }
50% { border-color: var(--status-running); opacity: 1; }
}
.lt-item-running { animation: exec-running-pulse 2s ease-in-out infinite; }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -19,14 +19,6 @@ function closeAdvancedSearch() {
lt.modal.close('advancedSearchModal');
}
// Close modal when clicking on backdrop
function closeOnAdvancedSearchBackdropClick(event) {
const modal = document.getElementById('advancedSearchModal');
if (event.target === modal) {
closeAdvancedSearch();
}
}
// Load users for dropdown
async function loadUsersForSearch() {
try {
@@ -148,7 +140,7 @@ 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;
}

View File

@@ -65,20 +65,8 @@ function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = tru
// 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);
@@ -178,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);
});
}

View File

@@ -124,8 +124,7 @@
function _dismissToast(toast) {
if (!toast || !toast.parentNode) return;
clearTimeout(toast._lt_timer);
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s ease';
toast.classList.add('lt-toast--hiding');
setTimeout(() => {
if (toast.parentNode) toast.parentNode.removeChild(toast);
_toastActive = false;
@@ -176,11 +175,11 @@
lt.modal.closeAll();
HTML contract:
<div id="my-modal-id" class="lt-modal-overlay">
<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">Title</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<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>
@@ -295,7 +294,6 @@
if (!overlay || !pre) return;
overlay.style.display = 'flex';
overlay.style.opacity = '1';
const name = (appName || 'TERMINAL').toUpperCase();
const titleStr = name + ' v1.0';
@@ -653,7 +651,6 @@
const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
ths.forEach((th, colIdx) => {
th.style.cursor = 'pointer';
let dir = 'asc';
th.addEventListener('click', () => {

View File

@@ -58,7 +58,7 @@ function initMobileSidebar() {
const toggleBtn = document.createElement('button');
toggleBtn.id = 'mobileFilterToggle';
toggleBtn.className = 'mobile-filter-toggle';
toggleBtn.innerHTML = ' Filters & Search Options';
toggleBtn.innerHTML = '[ = ] Filters & Search Options';
toggleBtn.onclick = openMobileSidebar;
dashboardMain.insertBefore(toggleBtn, dashboardMain.firstChild);
}
@@ -79,20 +79,20 @@ function initMobileSidebar() {
nav.className = 'mobile-bottom-nav';
nav.innerHTML = `
<a href="/">
<span class="nav-icon">🏠</span>
<span class="nav-label">Home</span>
<span class="nav-icon">[ ~ ]</span>
<span class="nav-label">HOME</span>
</a>
<button type="button" data-action="open-mobile-sidebar">
<span class="nav-icon">🔍</span>
<span class="nav-label">Filter</span>
<span class="nav-icon">[ / ]</span>
<span class="nav-label">FILTER</span>
</button>
<a href="/ticket/create">
<span class="nav-icon"></span>
<span class="nav-label">New</span>
<span class="nav-icon">[ + ]</span>
<span class="nav-label">NEW</span>
</a>
<button type="button" data-action="open-settings-modal">
<span class="nav-icon"></span>
<span class="nav-label">Settings</span>
<span class="nav-icon">[ * ]</span>
<span class="nav-label">CFG</span>
</button>
`;
document.body.appendChild(nav);
@@ -261,7 +261,6 @@ function clearAllFilters() {
function initTableSorting() {
const tableHeaders = document.querySelectorAll('th');
tableHeaders.forEach((header, index) => {
header.style.cursor = 'pointer';
header.addEventListener('click', () => {
const table = header.closest('table');
sortTable(table, index);
@@ -366,11 +365,13 @@ function sortTable(table, column) {
const aValue = a.children[column].textContent.trim();
const bValue = b.children[column].textContent.trim();
// Check if this is a date column
// Check if this is a date column — prefer data-ts attribute over text (which may be relative)
const headerText = headers[column].textContent.toLowerCase();
if (headerText === 'created' || headerText === 'updated') {
const dateA = new Date(aValue);
const dateB = new Date(bValue);
const cellA = a.children[column];
const cellB = b.children[column];
const dateA = new Date(cellA.dataset.ts || aValue);
const dateB = new Date(cellB.dataset.ts || bValue);
return currentDirection === 'asc' ? dateA - dateB : dateB - dateA;
}
@@ -520,24 +521,7 @@ function quickSave() {
priority: parseInt(prioritySelect.value)
};
fetch('/api/update_ticket.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(response => {
return response.text().then(text => {
try {
return JSON.parse(text);
} catch (e) {
throw new Error('Invalid JSON response: ' + text);
}
});
})
lt.api.post('/api/update_ticket.php', data)
.then(result => {
if (result.success) {
// Update the hamburger menu display
@@ -566,11 +550,11 @@ function quickSave() {
}
} else {
toast.error('Error updating ticket: ' + (result.error || 'Unknown error'), 5000);
lt.toast.error('Error updating ticket: ' + (result.error || 'Unknown error'), 5000);
}
})
.catch(error => {
toast.error('Error updating ticket: ' + error.message, 5000);
lt.toast.error('Error updating ticket: ' + error.message, 5000);
});
}
@@ -587,19 +571,7 @@ function saveTicket() {
}
});
fetch('/api/update_ticket.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: ticketId,
...data
})
})
.then(response => response.json())
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, ...data })
.then(data => {
if(data.success) {
const statusDisplay = document.getElementById('statusDisplay');
@@ -620,6 +592,7 @@ function saveTicket() {
*/
function loadTemplate() {
const templateSelect = document.getElementById('templateSelect');
if (!templateSelect) return;
const templateId = templateSelect.value;
if (!templateId) {
@@ -637,15 +610,7 @@ function loadTemplate() {
}
// Fetch template data
fetch(`/api/get_template.php?template_id=${templateId}`, {
credentials: 'same-origin'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch template');
}
return response.json();
})
lt.api.get(`/api/get_template.php?template_id=${templateId}`)
.then(data => {
if (data.success && data.template) {
const template = data.template;
@@ -671,11 +636,11 @@ function loadTemplate() {
document.getElementById('priority').value = template.default_priority;
}
} else {
toast.error('Failed to load template: ' + (data.error || 'Unknown error'), 4000);
lt.toast.error('Failed to load template: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
toast.error('Error loading template: ' + error.message, 4000);
lt.toast.error('Error loading template: ' + error.message, 4000);
});
}
@@ -685,6 +650,7 @@ function loadTemplate() {
function toggleSelectAll() {
const selectAll = document.getElementById('selectAllCheckbox');
if (!selectAll) return;
const checkboxes = document.querySelectorAll('.ticket-checkbox');
checkboxes.forEach(checkbox => {
@@ -717,22 +683,14 @@ function updateSelectionCount() {
const exportCount = document.getElementById('exportCount');
if (toolbar && countDisplay) {
if (count > 0) {
toolbar.style.display = 'flex';
countDisplay.textContent = count;
} else {
toolbar.style.display = 'none';
}
toolbar.classList.toggle('is-visible', count > 0);
if (count > 0) countDisplay.textContent = count;
}
// Show/hide export dropdown based on selection
if (exportDropdown) {
if (count > 0) {
exportDropdown.style.display = '';
if (exportCount) exportCount.textContent = count;
} else {
exportDropdown.style.display = 'none';
}
exportDropdown.classList.toggle('is-visible', count > 0);
if (count > 0 && exportCount) exportCount.textContent = count;
}
}
@@ -752,7 +710,7 @@ function bulkClose() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
toast.warning('No tickets selected', 2000);
lt.toast.warning('No tickets selected', 2000);
return;
}
@@ -766,33 +724,24 @@ function bulkClose() {
function performBulkCloseAction(ticketIds) {
fetch('/api/bulk_operation.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
operation_type: 'bulk_close',
ticket_ids: ticketIds
})
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_close',
ticket_ids: ticketIds
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (data.failed > 0) {
toast.warning(`Bulk close: ${data.processed} succeeded, ${data.failed} failed`, 5000);
lt.toast.warning(`Bulk close: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
toast.error('Bulk close failed: ' + error.message, 5000);
lt.toast.error('Bulk close failed: ' + error.message, 5000);
});
}
@@ -800,27 +749,27 @@ function showBulkAssignModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
toast.warning('No tickets selected', 2000);
lt.toast.warning('No tickets selected', 2000);
return;
}
// Create modal HTML
const modalHtml = `
<div class="lt-modal-overlay" id="bulkAssignModal" aria-hidden="true">
<div class="lt-modal-overlay" id="bulkAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkAssignModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title">Assign ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<span class="lt-modal-title" id="bulkAssignModalTitle">Assign ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<label for="bulkAssignUser">Assign to:</label>
<select id="bulkAssignUser" class="lt-select" style="width:100%;margin-top:0.5rem;">
<select id="bulkAssignUser" class="lt-select">
<option value="">Select User...</option>
</select>
</div>
<div class="lt-modal-footer">
<button data-action="perform-bulk-assign" class="lt-btn lt-btn-primary">Assign</button>
<button data-action="close-bulk-assign-modal" class="lt-btn lt-btn-ghost">Cancel</button>
<button data-action="perform-bulk-assign" class="lt-btn lt-btn-primary">ASSIGN</button>
<button data-action="close-bulk-assign-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
</div>
@@ -834,12 +783,14 @@ function showBulkAssignModal() {
.then(data => {
if (data.success && data.users) {
const select = document.getElementById('bulkAssignUser');
data.users.forEach(user => {
const option = document.createElement('option');
option.value = user.user_id;
option.textContent = user.display_name || user.username;
select.appendChild(option);
});
if (select) {
data.users.forEach(user => {
const option = document.createElement('option');
option.value = user.user_id;
option.textContent = user.display_name || user.username;
select.appendChild(option);
});
}
}
})
.catch(() => lt.toast.error('Error loading users'));
@@ -856,39 +807,30 @@ function performBulkAssign() {
const ticketIds = getSelectedTicketIds();
if (!userId) {
toast.warning('Please select a user', 2000);
lt.toast.warning('Please select a user', 2000);
return;
}
fetch('/api/bulk_operation.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
operation_type: 'bulk_assign',
ticket_ids: ticketIds,
parameters: { assigned_to: parseInt(userId) }
})
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_assign',
ticket_ids: ticketIds,
parameters: { assigned_to: parseInt(userId) }
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeBulkAssignModal();
if (data.failed > 0) {
toast.warning(`Bulk assign: ${data.processed} succeeded, ${data.failed} failed`, 5000);
lt.toast.warning(`Bulk assign: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
toast.success(`Successfully assigned ${data.processed} ticket(s)`, 4000);
lt.toast.success(`Successfully assigned ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
toast.error('Bulk assign failed: ' + error.message, 5000);
lt.toast.error('Bulk assign failed: ' + error.message, 5000);
});
}
@@ -896,20 +838,20 @@ function showBulkPriorityModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
toast.warning('No tickets selected', 2000);
lt.toast.warning('No tickets selected', 2000);
return;
}
const modalHtml = `
<div class="lt-modal-overlay" id="bulkPriorityModal" aria-hidden="true">
<div class="lt-modal-overlay" id="bulkPriorityModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkPriorityModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title">Change Priority for ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<span class="lt-modal-title" id="bulkPriorityModalTitle">Change Priority for ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<label for="bulkPriority">Priority:</label>
<select id="bulkPriority" class="lt-select" style="width:100%;margin-top:0.5rem;">
<select id="bulkPriority" class="lt-select">
<option value="">Select Priority...</option>
<option value="1">P1 - Critical Impact</option>
<option value="2">P2 - High Impact</option>
@@ -919,8 +861,8 @@ function showBulkPriorityModal() {
</select>
</div>
<div class="lt-modal-footer">
<button data-action="perform-bulk-priority" class="lt-btn lt-btn-primary">Update</button>
<button data-action="close-bulk-priority-modal" class="lt-btn lt-btn-ghost">Cancel</button>
<button data-action="perform-bulk-priority" class="lt-btn lt-btn-primary">UPDATE</button>
<button data-action="close-bulk-priority-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
</div>
@@ -941,39 +883,30 @@ function performBulkPriority() {
const ticketIds = getSelectedTicketIds();
if (!priority) {
toast.warning('Please select a priority', 2000);
lt.toast.warning('Please select a priority', 2000);
return;
}
fetch('/api/bulk_operation.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
operation_type: 'bulk_priority',
ticket_ids: ticketIds,
parameters: { priority: parseInt(priority) }
})
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_priority',
ticket_ids: ticketIds,
parameters: { priority: parseInt(priority) }
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeBulkPriorityModal();
if (data.failed > 0) {
toast.warning(`Priority update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
lt.toast.warning(`Priority update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
toast.success(`Successfully updated priority for ${data.processed} ticket(s)`, 4000);
lt.toast.success(`Successfully updated priority for ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
toast.error('Bulk priority update failed: ' + error.message, 5000);
lt.toast.error('Bulk priority update failed: ' + error.message, 5000);
});
}
@@ -985,7 +918,6 @@ document.addEventListener('DOMContentLoaded', function() {
if (row.dataset.clickable) return;
row.dataset.clickable = 'true';
row.style.cursor = 'pointer';
row.addEventListener('click', function(e) {
// Don't navigate if clicking on a link, button, checkbox, or select
@@ -1013,20 +945,20 @@ function showBulkStatusModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
toast.warning('No tickets selected', 2000);
lt.toast.warning('No tickets selected', 2000);
return;
}
const modalHtml = `
<div class="lt-modal-overlay" id="bulkStatusModal" aria-hidden="true">
<div class="lt-modal-overlay" id="bulkStatusModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkStatusModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title">Change Status for ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<span class="lt-modal-title" id="bulkStatusModalTitle">Change Status for ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<label for="bulkStatus">New Status:</label>
<select id="bulkStatus" class="lt-select" style="width:100%;margin-top:0.5rem;">
<select id="bulkStatus" class="lt-select">
<option value="">Select Status...</option>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
@@ -1035,8 +967,8 @@ function showBulkStatusModal() {
</select>
</div>
<div class="lt-modal-footer">
<button data-action="perform-bulk-status" class="lt-btn lt-btn-primary">Update</button>
<button data-action="close-bulk-status-modal" class="lt-btn lt-btn-ghost">Cancel</button>
<button data-action="perform-bulk-status" class="lt-btn lt-btn-primary">UPDATE</button>
<button data-action="close-bulk-status-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
</div>
@@ -1057,39 +989,30 @@ function performBulkStatusChange() {
const ticketIds = getSelectedTicketIds();
if (!status) {
toast.warning('Please select a status', 2000);
lt.toast.warning('Please select a status', 2000);
return;
}
fetch('/api/bulk_operation.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
operation_type: 'bulk_status',
ticket_ids: ticketIds,
parameters: { status: status }
})
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_status',
ticket_ids: ticketIds,
parameters: { status: status }
})
.then(response => response.json())
.then(data => {
closeBulkStatusModal();
if (data.success) {
if (data.failed > 0) {
toast.warning(`Status update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
lt.toast.warning(`Status update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
lt.toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
toast.error('Bulk status change failed: ' + error.message, 5000);
lt.toast.error('Bulk status change failed: ' + error.message, 5000);
});
}
@@ -1098,24 +1021,24 @@ function showBulkDeleteModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
toast.warning('No tickets selected', 2000);
lt.toast.warning('No tickets selected', 2000);
return;
}
const modalHtml = `
<div class="lt-modal-overlay" id="bulkDeleteModal" aria-hidden="true">
<div class="lt-modal">
<div class="lt-modal-header" style="color: var(--status-closed);">
<span class="lt-modal-title">⚠ Delete ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<div class="lt-modal-overlay" id="bulkDeleteModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkDeleteModalTitle">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header lt-modal-header--danger">
<span class="lt-modal-title" id="bulkDeleteModalTitle">[ ! ] DELETE ${ticketIds.length} TICKET(S)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body" style="text-align:center;">
<p style="color: var(--terminal-amber); font-size: 1.1rem; margin-bottom: 1rem;">This action cannot be undone!</p>
<p style="color: var(--terminal-green);">You are about to permanently delete ${ticketIds.length} ticket(s).<br>All associated comments and history will be lost.</p>
<div class="lt-modal-body text-center">
<p class="modal-warning-text">This action cannot be undone!</p>
<p class="text-green">You are about to permanently delete ${ticketIds.length} ticket(s).<br>All associated comments and history will be lost.</p>
</div>
<div class="lt-modal-footer">
<button data-action="perform-bulk-delete" class="lt-btn lt-btn-primary" style="background: var(--status-closed); border-color: var(--status-closed);">Delete Permanently</button>
<button data-action="close-bulk-delete-modal" class="lt-btn lt-btn-ghost">Cancel</button>
<button data-action="perform-bulk-delete" class="lt-btn lt-btn-danger">DELETE PERMANENTLY</button>
<button data-action="close-bulk-delete-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
</div>
@@ -1134,30 +1057,21 @@ function closeBulkDeleteModal() {
function performBulkDelete() {
const ticketIds = getSelectedTicketIds();
fetch('/api/bulk_operation.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
operation_type: 'bulk_delete',
ticket_ids: ticketIds
})
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_delete',
ticket_ids: ticketIds
})
.then(response => response.json())
.then(data => {
closeBulkDeleteModal();
if (data.success) {
toast.success(`Successfully deleted ${ticketIds.length} ticket(s)`, 4000);
lt.toast.success(`Successfully deleted ${ticketIds.length} ticket(s)`, 4000);
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
toast.error('Bulk delete failed: ' + error.message, 5000);
lt.toast.error('Bulk delete failed: ' + error.message, 5000);
});
}
@@ -1186,29 +1100,29 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel
// Icon based on type
const icons = {
warning: '',
error: '✗',
info: ''
warning: '[ ! ]',
error: '[ X ]',
info: '[ i ]',
};
const icon = icons[type] || icons.warning;
// Escape user-provided content to prevent XSS
const safeTitle = escapeHtml(title);
const safeMessage = escapeHtml(message);
const safeTitle = lt.escHtml(title);
const safeMessage = lt.escHtml(message);
const modalHtml = `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true">
<div class="lt-modal" style="max-width: 500px;">
<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">${icon} ${safeTitle}</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<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" style="text-align: center;">
<p style="color: var(--terminal-green); white-space: pre-line;">${safeMessage}</p>
<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>
<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>
@@ -1243,24 +1157,24 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
const inputId = modalId + '_input';
// Escape user-provided content to prevent XSS
const safeTitle = escapeHtml(title);
const safeLabel = escapeHtml(label);
const safePlaceholder = escapeHtml(placeholder);
const safeTitle = lt.escHtml(title);
const safeLabel = lt.escHtml(label);
const safePlaceholder = lt.escHtml(placeholder);
const modalHtml = `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true">
<div class="lt-modal" style="max-width: 500px;">
<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">
<span class="lt-modal-title">${safeTitle}</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<span class="lt-modal-title" id="${modalId}_title">${safeTitle}</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<label for="${inputId}" style="display: block; margin-bottom: 0.5rem; color: var(--terminal-green);">${safeLabel}</label>
<input type="text" id="${inputId}" class="lt-input" placeholder="${safePlaceholder}" style="width: 100%;" />
<label for="${inputId}">${safeLabel}</label>
<input type="text" id="${inputId}" class="lt-input" placeholder="${safePlaceholder}" />
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="${modalId}_submit">Save</button>
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">Cancel</button>
<button class="lt-btn lt-btn-primary" id="${modalId}_submit">SAVE</button>
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
</div>
</div>
</div>
@@ -1300,23 +1214,23 @@ function quickStatusChange(ticketId, currentStatus) {
const otherStatuses = statuses.filter(s => s !== currentStatus);
const modalHtml = `
<div class="lt-modal-overlay" id="quickStatusModal" aria-hidden="true">
<div class="lt-modal" style="max-width:400px;">
<div class="lt-modal-overlay" id="quickStatusModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="quickStatusModalTitle">
<div class="lt-modal lt-modal-xs">
<div class="lt-modal-header">
<span class="lt-modal-title">Quick Status Change</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<span class="lt-modal-title" id="quickStatusModalTitle">Quick Status Change</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<p style="margin-bottom:0.5rem;">Ticket #${escapeHtml(ticketId)}</p>
<p style="margin-bottom:0.5rem;color:var(--terminal-amber);">Current: ${escapeHtml(currentStatus)}</p>
<p class="mb-half">Ticket #${lt.escHtml(ticketId)}</p>
<p class="text-amber mb-half">Current: ${lt.escHtml(currentStatus)}</p>
<label for="quickStatusSelect">New Status:</label>
<select id="quickStatusSelect" class="lt-select" style="width:100%;margin-top:0.5rem;">
<select id="quickStatusSelect" class="lt-select">
${otherStatuses.map(s => `<option value="${s}">${s}</option>`).join('')}
</select>
</div>
<div class="lt-modal-footer">
<button data-action="perform-quick-status" data-ticket-id="${ticketId}" class="lt-btn lt-btn-primary">Update</button>
<button data-action="close-quick-status-modal" class="lt-btn lt-btn-ghost">Cancel</button>
<button data-action="perform-quick-status" data-ticket-id="${ticketId}" class="lt-btn lt-btn-primary">UPDATE</button>
<button data-action="close-quick-status-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
</div>
@@ -1335,31 +1249,19 @@ function closeQuickStatusModal() {
function performQuickStatusChange(ticketId) {
const newStatus = document.getElementById('quickStatusSelect').value;
fetch('/api/update_ticket.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: ticketId,
status: newStatus
})
})
.then(response => response.json())
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
.then(data => {
closeQuickStatusModal();
if (data.success) {
toast.success(`Status updated to ${newStatus}`, 3000);
lt.toast.success(`Status updated to ${newStatus}`, 3000);
setTimeout(() => window.location.reload(), 1000);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
closeQuickStatusModal();
toast.error('Error updating status', 4000);
lt.toast.error('Error updating status', 4000);
});
}
@@ -1368,22 +1270,22 @@ function performQuickStatusChange(ticketId) {
*/
function quickAssign(ticketId) {
const modalHtml = `
<div class="lt-modal-overlay" id="quickAssignModal" aria-hidden="true">
<div class="lt-modal" style="max-width:400px;">
<div class="lt-modal-overlay" id="quickAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="quickAssignModalTitle">
<div class="lt-modal lt-modal-xs">
<div class="lt-modal-header">
<span class="lt-modal-title">Quick Assign</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<span class="lt-modal-title" id="quickAssignModalTitle">Quick Assign</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<p style="margin-bottom:0.5rem;">Ticket #${escapeHtml(ticketId)}</p>
<p class="mb-half">Ticket #${lt.escHtml(ticketId)}</p>
<label for="quickAssignSelect">Assign to:</label>
<select id="quickAssignSelect" class="lt-select" style="width:100%;margin-top:0.5rem;">
<select id="quickAssignSelect" class="lt-select">
<option value="">Unassigned</option>
</select>
</div>
<div class="lt-modal-footer">
<button data-action="perform-quick-assign" data-ticket-id="${ticketId}" class="lt-btn lt-btn-primary">Assign</button>
<button data-action="close-quick-assign-modal" class="lt-btn lt-btn-ghost">Cancel</button>
<button data-action="perform-quick-assign" data-ticket-id="${ticketId}" class="lt-btn lt-btn-primary">ASSIGN</button>
<button data-action="close-quick-assign-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
</div>
@@ -1417,31 +1319,19 @@ function closeQuickAssignModal() {
function performQuickAssign(ticketId) {
const assignedTo = document.getElementById('quickAssignSelect').value || null;
fetch('/api/assign_ticket.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: ticketId,
assigned_to: assignedTo
})
})
.then(response => response.json())
lt.api.post('/api/assign_ticket.php', { ticket_id: ticketId, assigned_to: assignedTo })
.then(data => {
closeQuickAssignModal();
if (data.success) {
toast.success('Assignment updated', 3000);
lt.toast.success('Assignment updated', 3000);
setTimeout(() => window.location.reload(), 1000);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
closeQuickAssignModal();
toast.error('Error updating assignment', 4000);
lt.toast.error('Error updating assignment', 4000);
});
}
@@ -1461,14 +1351,14 @@ function setViewMode(mode) {
if (!tableView || !cardView) return;
if (mode === 'card') {
tableView.style.display = 'none';
cardView.style.display = 'block';
tableView.classList.add('is-hidden');
cardView.classList.remove('is-hidden');
tableBtn.classList.remove('active');
cardBtn.classList.add('active');
populateKanbanCards();
} else {
tableView.style.display = 'block';
cardView.style.display = 'none';
tableView.classList.remove('is-hidden');
cardView.classList.add('is-hidden');
tableBtn.classList.add('active');
cardBtn.classList.remove('active');
}
@@ -1522,13 +1412,13 @@ function populateKanbanCards() {
card.onclick = () => window.location.href = `/ticket/${ticketId}`;
card.innerHTML = `
<div class="card-header">
<span class="card-id">#${escapeHtml(ticketId)}</span>
<span class="card-id">#${lt.escHtml(ticketId)}</span>
<span class="card-priority p${priority}">P${priority}</span>
</div>
<div class="card-title">${escapeHtml(title)}</div>
<div class="card-title">${lt.escHtml(title)}</div>
<div class="card-footer">
<span class="card-category">${escapeHtml(category)}</span>
<span class="card-assignee" title="${escapeHtml(assignedTo)}">${escapeHtml(initials)}</span>
<span class="card-category">${lt.escHtml(category)}</span>
<span class="card-assignee" title="${lt.escHtml(assignedTo)}">${lt.escHtml(initials)}</span>
</div>
`;
column.appendChild(card);
@@ -1546,8 +1436,7 @@ function populateKanbanCards() {
document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem('ticketViewMode');
if (savedMode === 'card') {
// Delay to ensure DOM is ready
setTimeout(() => setViewMode('card'), 100);
setViewMode('card');
}
});
@@ -1562,8 +1451,7 @@ function initTicketPreview() {
// Create preview element
const preview = document.createElement('div');
preview.id = 'ticketPreview';
preview.className = 'ticket-preview-popup';
preview.style.display = 'none';
preview.className = 'ticket-preview-popup is-hidden';
document.body.appendChild(preview);
currentPreview = preview;
@@ -1615,17 +1503,17 @@ function showTicketPreview(event) {
// Build preview content
currentPreview.innerHTML = `
<div class="preview-header">
<span class="preview-id">#${escapeHtml(ticketId)}</span>
<span class="preview-status status-${status.replace(/\s+/g, '-')}">${escapeHtml(status)}</span>
<span class="preview-id">#${lt.escHtml(ticketId)}</span>
<span class="preview-status status-${status.replace(/\s+/g, '-')}">${lt.escHtml(status)}</span>
</div>
<div class="preview-title">${escapeHtml(title)}</div>
<div class="preview-title">${lt.escHtml(title)}</div>
<div class="preview-meta">
<div><strong>Priority:</strong> P${escapeHtml(priority)}</div>
<div><strong>Category:</strong> ${escapeHtml(category)}</div>
<div><strong>Type:</strong> ${escapeHtml(type)}</div>
<div><strong>Assigned:</strong> ${escapeHtml(assignedTo)}</div>
<div><strong>Priority:</strong> P${lt.escHtml(priority)}</div>
<div><strong>Category:</strong> ${lt.escHtml(category)}</div>
<div><strong>Type:</strong> ${lt.escHtml(type)}</div>
<div><strong>Assigned:</strong> ${lt.escHtml(assignedTo)}</div>
</div>
<div class="preview-footer">Created by ${escapeHtml(createdBy)}</div>
<div class="preview-footer">Created by ${lt.escHtml(createdBy)}</div>
`;
// Position the preview
@@ -1646,7 +1534,7 @@ function showTicketPreview(event) {
currentPreview.style.left = left + 'px';
currentPreview.style.top = top + 'px';
currentPreview.style.display = 'block';
currentPreview.classList.remove('is-hidden');
}, 300);
}
@@ -1656,7 +1544,7 @@ function hideTicketPreview() {
}
previewTimeout = setTimeout(() => {
if (currentPreview) {
currentPreview.style.display = 'none';
currentPreview.classList.add('is-hidden');
}
}, 100);
}
@@ -1697,7 +1585,7 @@ function exportSelectedTickets(format) {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
toast.warning('No tickets selected', 2000);
lt.toast.warning('No tickets selected', 2000);
return;
}
@@ -1797,7 +1685,7 @@ function showLoadingOverlay(element, message = 'Loading...') {
<div class="loading-spinner"></div>
<div class="loading-text">${message}</div>
`;
element.style.position = 'relative';
element.classList.add('has-overlay');
element.appendChild(overlay);
}
@@ -1807,12 +1695,57 @@ function showLoadingOverlay(element, message = 'Loading...') {
function hideLoadingOverlay(element) {
const overlay = element.querySelector('.loading-overlay');
if (overlay) {
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.3s';
setTimeout(() => overlay.remove(), 300);
overlay.classList.add('loading-overlay--hiding');
setTimeout(() => {
overlay.remove();
element.classList.remove('has-overlay');
}, 300);
}
}
// ========================================
// AUTO-REFRESH (lt.autoRefresh integration)
// ========================================
/**
* Reload the dashboard, but skip if a modal is open or user is typing.
* Registered with lt.autoRefresh so it runs every 5 minutes automatically.
*/
function dashboardAutoRefresh() {
// Don't interrupt the user if a modal is open
if (document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) return;
// Don't interrupt if focus is in a text input
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
window.location.reload();
}
document.addEventListener('DOMContentLoaded', function() {
// Only run auto-refresh on the dashboard, not on ticket pages
if (!window.location.pathname.includes('/ticket/')) {
lt.autoRefresh.start(dashboardAutoRefresh, 5 * 60 * 1000);
}
});
// ========================================
// RELATIVE TIMESTAMPS
// ========================================
/**
* Convert all .ts-cell[data-ts] elements to relative time using lt.time.ago().
* Runs once on DOMContentLoaded and refreshes every 60s so "2m ago" stays current.
* The original full timestamp is preserved in the title attribute for hover.
*/
function initRelativeTimes() {
document.querySelectorAll('.ts-cell[data-ts]').forEach(el => {
el.textContent = lt.time.ago(el.dataset.ts);
});
}
document.addEventListener('DOMContentLoaded', initRelativeTimes);
setInterval(initRelativeTimes, 60000);
// Export for use in other scripts
window.generateSkeletonRows = generateSkeletonRows;
window.generateSkeletonComments = generateSkeletonComments;

View File

@@ -33,43 +33,46 @@ function showKeyboardHelp() {
modal.id = 'keyboardHelpModal';
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="lt-modal" style="max-width: 500px;">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header">
<span class="lt-modal-title">KEYBOARD SHORTCUTS</span>
<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 style="color: var(--terminal-amber); margin: 0 0 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>
<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 style="color: var(--terminal-amber); margin: 0 0 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>
<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 style="color: var(--terminal-amber); margin: 0 0 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>
<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 style="color: var(--terminal-amber); margin: 0 0 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>
<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>
<button class="lt-btn lt-btn-ghost" data-modal-close>CLOSE</button>
</div>
</div>
`;

View File

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

View File

@@ -5,7 +5,7 @@ function toggleVisibilityGroupsEdit() {
const visibility = document.getElementById('visibilitySelect')?.value;
const groupsField = document.getElementById('visibilityGroupsField');
if (groupsField) {
groupsField.style.display = visibility === 'internal' ? 'block' : 'none';
groupsField.classList.toggle('is-hidden', visibility !== 'internal');
}
}
@@ -48,40 +48,21 @@ function saveTicket() {
}
// Use the correct API path
const apiUrl = '/api/update_ticket.php';
fetch(apiUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: ticketId,
...data
})
})
.then(response => {
if (!response.ok) {
return response.text().then(text => {
throw new Error('Network response was not ok');
});
}
return response.json();
})
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, ...data })
.then(data => {
if(data.success) {
if (data.success) {
const statusDisplay = document.getElementById('statusDisplay');
if (statusDisplay) {
statusDisplay.className = `status-${data.status}`;
statusDisplay.textContent = data.status;
}
toast.success('Ticket updated successfully');
lt.toast.success('Ticket updated successfully');
} else {
lt.toast.error('Error saving ticket: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
lt.toast.error('Error saving ticket: ' + error.message);
});
}
@@ -105,6 +86,8 @@ function toggleEditMode() {
// Enable description (textarea)
if (descriptionField) {
descriptionField.disabled = false;
descriptionField.style.height = 'auto';
descriptionField.style.height = descriptionField.scrollHeight + 'px';
}
// Enable metadata fields (priority, category, type)
@@ -134,7 +117,9 @@ function toggleEditMode() {
}
function addComment() {
const commentText = document.getElementById('newComment').value;
const newComment = document.getElementById('newComment');
if (!newComment) return;
const commentText = newComment.value;
if (!commentText.trim()) {
return;
}
@@ -145,33 +130,19 @@ function addComment() {
return;
}
const isMarkdownEnabled = document.getElementById('markdownMaster').checked;
const markdownMaster = document.getElementById('markdownMaster');
const isMarkdownEnabled = markdownMaster ? markdownMaster.checked : false;
fetch('/api/add_comment.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: ticketId,
comment_text: commentText,
markdown_enabled: isMarkdownEnabled
})
})
.then(response => {
if (!response.ok) {
return response.text().then(text => {
throw new Error('Network response was not ok');
});
}
return response.json();
lt.api.post('/api/add_comment.php', {
ticket_id: ticketId,
comment_text: commentText,
markdown_enabled: isMarkdownEnabled
})
.then(data => {
if(data.success) {
// Clear the comment box
document.getElementById('newComment').value = '';
const nc = document.getElementById('newComment');
if (nc) nc.value = '';
// Format the comment text for display
let displayText;
@@ -226,9 +197,11 @@ function addComment() {
function togglePreview() {
const preview = document.getElementById('markdownPreview');
const textarea = document.getElementById('newComment');
const isPreviewEnabled = document.getElementById('markdownToggle').checked;
const toggleEl = document.getElementById('markdownToggle');
if (!preview || !textarea || !toggleEl) return;
preview.style.display = isPreviewEnabled ? 'block' : 'none';
const isPreviewEnabled = toggleEl.checked;
preview.classList.toggle('is-hidden', !isPreviewEnabled);
if (isPreviewEnabled) {
preview.innerHTML = parseMarkdown(textarea.value);
@@ -239,27 +212,33 @@ function togglePreview() {
}
function updatePreview() {
const commentText = document.getElementById('newComment').value;
const textarea = document.getElementById('newComment');
const previewDiv = document.getElementById('markdownPreview');
const isMarkdownEnabled = document.getElementById('markdownMaster').checked;
const masterEl = document.getElementById('markdownMaster');
if (!textarea || !previewDiv || !masterEl) return;
const commentText = textarea.value;
const isMarkdownEnabled = masterEl.checked;
if (isMarkdownEnabled && commentText.trim()) {
// For markdown preview, use parseMarkdown which handles line breaks correctly
previewDiv.innerHTML = parseMarkdown(commentText);
previewDiv.style.display = 'block';
previewDiv.classList.remove('is-hidden');
} else {
previewDiv.style.display = 'none';
previewDiv.classList.add('is-hidden');
}
}
function toggleMarkdownMode() {
const previewToggle = document.getElementById('markdownToggle');
const isMasterEnabled = document.getElementById('markdownMaster').checked;
const masterEl = document.getElementById('markdownMaster');
if (!previewToggle || !masterEl) return;
const isMasterEnabled = masterEl.checked;
previewToggle.disabled = !isMasterEnabled;
if (!isMasterEnabled) {
previewToggle.checked = false;
document.getElementById('markdownPreview').style.display = 'none';
const preview = document.getElementById('markdownPreview');
if (preview) preview.classList.add('is-hidden');
}
}
@@ -305,23 +284,14 @@ function handleAssignmentChange() {
const ticketId = window.ticketData.id;
const assignedTo = this.value || null;
fetch('/api/assign_ticket.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ ticket_id: ticketId, assigned_to: assignedTo })
})
.then(response => response.json())
lt.api.post('/api/assign_ticket.php', { ticket_id: ticketId, assigned_to: assignedTo })
.then(data => {
if (!data.success) {
toast.error('Error updating assignment');
lt.toast.error('Error updating assignment');
}
})
.catch(error => {
toast.error('Error updating assignment: ' + error.message);
lt.toast.error('Error updating assignment: ' + error.message);
});
});
}
@@ -338,22 +308,13 @@ function handleMetadataChanges() {
function updateTicketField(fieldName, newValue) {
const ticketId = window.ticketData.id;
fetch('/api/update_ticket.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: ticketId,
[fieldName]: fieldName === 'priority' ? parseInt(newValue) : newValue
})
lt.api.post('/api/update_ticket.php', {
ticket_id: ticketId,
[fieldName]: fieldName === 'priority' ? parseInt(newValue) : newValue
})
.then(response => response.json())
.then(data => {
if (!data.success) {
toast.error(`Error updating ${fieldName}`);
lt.toast.error(`Error updating ${fieldName}`);
} else {
// Update window.ticketData
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
@@ -375,7 +336,7 @@ function handleMetadataChanges() {
}
})
.catch(error => {
toast.error(`Error updating ${fieldName}: ` + error.message);
lt.toast.error(`Error updating ${fieldName}: ` + error.message);
});
}
@@ -444,35 +405,7 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
}
// Update status via API
fetch('/api/update_ticket.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: ticketId,
status: newStatus
})
})
.then(async response => {
const text = await response.text();
if (!response.ok) {
try {
const data = JSON.parse(text);
throw new Error(data.error || 'Server returned an error');
} catch (parseError) {
throw new Error(text || 'Network response was not ok');
}
}
try {
return JSON.parse(text);
} catch (parseError) {
throw new Error('Invalid JSON response from server');
}
})
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
.then(data => {
if (data.success) {
// Update the dropdown to show new status as current
@@ -492,13 +425,13 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
window.location.reload();
}, 500);
} else {
toast.error('Error updating status: ' + (data.error || 'Unknown error'));
lt.toast.error('Error updating status: ' + (data.error || 'Unknown error'));
// Reset to current status
statusSelect.selectedIndex = 0;
}
})
.catch(error => {
toast.error('Error updating status: ' + error.message);
lt.toast.error('Error updating status: ' + error.message);
// Reset to current status
statusSelect.selectedIndex = 0;
});
@@ -517,17 +450,7 @@ function showTab(tabName) {
}
// Hide all tabs
descriptionTab.style.display = 'none';
commentsTab.style.display = 'none';
if (attachmentsTab) {
attachmentsTab.style.display = 'none';
}
if (dependenciesTab) {
dependenciesTab.style.display = 'none';
}
if (activityTab) {
activityTab.style.display = 'none';
}
document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active'));
// Remove active class and aria-selected from all buttons
document.querySelectorAll('.tab-btn').forEach(btn => {
@@ -536,10 +459,13 @@ function showTab(tabName) {
});
// Show selected tab and activate its button
document.getElementById(`${tabName}-tab`).style.display = 'block';
const tabEl = document.getElementById(`${tabName}-tab`);
if (tabEl) tabEl.classList.add('active');
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
activeBtn.classList.add('active');
activeBtn.setAttribute('aria-selected', 'true');
if (activeBtn) {
activeBtn.classList.add('active');
activeBtn.setAttribute('aria-selected', 'true');
}
// Load attachments when tab is shown
if (tabName === 'attachments') {
@@ -560,15 +486,7 @@ function showTab(tabName) {
function loadDependencies() {
const ticketId = window.ticketData.id;
fetch(`/api/ticket_dependencies.php?ticket_id=${ticketId}`, {
credentials: 'same-origin'
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
lt.api.get(`/api/ticket_dependencies.php?ticket_id=${ticketId}`)
.then(data => {
if (data.success) {
renderDependencies(data.dependencies);
@@ -587,10 +505,10 @@ function showDependencyError(message) {
const dependentsList = document.getElementById('dependentsList');
if (dependenciesList) {
dependenciesList.innerHTML = `<p style="color: var(--terminal-amber);">${escapeHtml(message)}</p>`;
dependenciesList.innerHTML = `<p class="text-amber">${lt.escHtml(message)}</p>`;
}
if (dependentsList) {
dependentsList.innerHTML = `<p style="color: var(--terminal-amber);">${escapeHtml(message)}</p>`;
dependentsList.innerHTML = `<p class="text-amber">${lt.escHtml(message)}</p>`;
}
}
@@ -612,19 +530,19 @@ function renderDependencies(dependencies) {
if (items.length > 0) {
hasAny = true;
html += `<div class="dependency-group">
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0;">${typeLabels[type]}</h4>`;
<h4>${typeLabels[type]}</h4>`;
items.forEach(dep => {
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
html += `<div class="dependency-item" style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px dashed var(--terminal-green-dim);">
html += `<div class="dependency-item">
<div>
<a href="/ticket/${escapeHtml(dep.depends_on_id)}" style="color: var(--terminal-green);">
#${escapeHtml(dep.depends_on_id)}
<a href="/ticket/${lt.escHtml(dep.depends_on_id)}">
#${lt.escHtml(dep.depends_on_id)}
</a>
<span style="margin-left: 0.5rem;">${escapeHtml(dep.title)}</span>
<span class="status-badge ${statusClass}" style="margin-left: 0.5rem; font-size: 0.8rem;">${escapeHtml(dep.status)}</span>
<span class="dependency-title">${lt.escHtml(dep.title)}</span>
<span class="status-badge ${statusClass}">${lt.escHtml(dep.status)}</span>
</div>
<button data-action="remove-dependency" data-dependency-id="${dep.dependency_id}" class="btn btn-small" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;">Remove</button>
<button data-action="remove-dependency" data-dependency-id="${dep.dependency_id}" class="btn btn-small">REMOVE</button>
</div>`;
});
@@ -633,7 +551,7 @@ function renderDependencies(dependencies) {
}
if (!hasAny) {
html = '<p style="color: var(--terminal-green-dim);">No dependencies configured.</p>';
html = '<p class="text-muted-green">No dependencies configured.</p>';
}
container.innerHTML = html;
@@ -644,21 +562,21 @@ function renderDependents(dependents) {
if (!container) return;
if (dependents.length === 0) {
container.innerHTML = '<p style="color: var(--terminal-green-dim);">No tickets depend on this one.</p>';
container.innerHTML = '<p class="text-muted-green">No tickets depend on this one.</p>';
return;
}
let html = '';
dependents.forEach(dep => {
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
html += `<div class="dependency-item" style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px dashed var(--terminal-green-dim);">
html += `<div class="dependency-item">
<div>
<a href="/ticket/${escapeHtml(dep.ticket_id)}" style="color: var(--terminal-green);">
#${escapeHtml(dep.ticket_id)}
<a href="/ticket/${lt.escHtml(dep.ticket_id)}">
#${lt.escHtml(dep.ticket_id)}
</a>
<span style="margin-left: 0.5rem;">${escapeHtml(dep.title)}</span>
<span class="status-badge ${statusClass}" style="margin-left: 0.5rem; font-size: 0.8rem;">${escapeHtml(dep.status)}</span>
<span style="margin-left: 0.5rem; color: var(--terminal-amber);">(${escapeHtml(dep.dependency_type)})</span>
<span class="dependency-title">${lt.escHtml(dep.title)}</span>
<span class="status-badge ${statusClass}">${lt.escHtml(dep.status)}</span>
<span class="dependency-title text-amber">(${lt.escHtml(dep.dependency_type)})</span>
</div>
</div>`;
});
@@ -668,70 +586,56 @@ function renderDependents(dependents) {
function addDependency() {
const ticketId = window.ticketData.id;
const dependsOnId = document.getElementById('dependencyTicketId').value.trim();
const dependencyType = document.getElementById('dependencyType').value;
const depIdEl = document.getElementById('dependencyTicketId');
const depTypeEl = document.getElementById('dependencyType');
if (!depIdEl || !depTypeEl) return;
const dependsOnId = depIdEl.value.trim();
const dependencyType = depTypeEl.value;
if (!dependsOnId) {
toast.warning('Please enter a ticket ID', 3000);
lt.toast.warning('Please enter a ticket ID', 3000);
return;
}
fetch('/api/ticket_dependencies.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: ticketId,
depends_on_id: dependsOnId,
dependency_type: dependencyType
})
lt.api.post('/api/ticket_dependencies.php', {
ticket_id: ticketId,
depends_on_id: dependsOnId,
dependency_type: dependencyType
})
.then(response => response.json())
.then(data => {
if (data.success) {
toast.success('Dependency added', 3000);
document.getElementById('dependencyTicketId').value = '';
lt.toast.success('Dependency added', 3000);
if (depIdEl) depIdEl.value = '';
loadDependencies();
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
toast.error('Error adding dependency', 4000);
lt.toast.error('Error adding dependency', 4000);
});
}
function removeDependency(dependencyId) {
if (!confirm('Are you sure you want to remove this dependency?')) {
return;
}
fetch('/api/ticket_dependencies.php', {
method: 'DELETE',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
dependency_id: dependencyId
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
toast.success('Dependency removed', 3000);
loadDependencies();
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
showConfirmModal(
'Remove Dependency',
'Are you sure you want to remove this dependency?',
'warning',
function() {
lt.api.delete('/api/ticket_dependencies.php', { dependency_id: dependencyId })
.then(data => {
if (data.success) {
lt.toast.success('Dependency removed', 3000);
loadDependencies();
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
lt.toast.error('Error removing dependency', 4000);
});
}
})
.catch(error => {
toast.error('Error removing dependency', 4000);
});
);
}
// ========================================
@@ -794,11 +698,12 @@ function handleFileUpload(files) {
const progressDiv = document.getElementById('uploadProgress');
const progressFill = document.getElementById('progressFill');
const statusText = document.getElementById('uploadStatus');
if (!progressDiv || !progressFill || !statusText) return;
let uploadedCount = 0;
const totalFiles = files.length;
progressDiv.style.display = 'block';
progressDiv.classList.remove('is-hidden');
statusText.textContent = `Uploading 0 of ${totalFiles} files...`;
progressFill.style.width = '0%';
@@ -828,18 +733,18 @@ function handleFileUpload(files) {
const response = JSON.parse(xhr.responseText);
if (response.success) {
if (uploadedCount === totalFiles) {
toast.success(`${totalFiles} file(s) uploaded successfully`, 3000);
lt.toast.success(`${totalFiles} file(s) uploaded successfully`, 3000);
loadAttachments();
resetUploadUI();
}
} else {
toast.error(`Error uploading ${file.name}: ${response.error}`, 4000);
lt.toast.error(`Error uploading ${file.name}: ${response.error}`, 4000);
}
} catch (e) {
toast.error(`Error parsing response for ${file.name}`, 4000);
lt.toast.error(`Error parsing response for ${file.name}`, 4000);
}
} else {
toast.error(`Error uploading ${file.name}: Server error`, 4000);
lt.toast.error(`Error uploading ${file.name}: Server error`, 4000);
}
if (uploadedCount === totalFiles) {
@@ -849,7 +754,7 @@ function handleFileUpload(files) {
xhr.addEventListener('error', () => {
uploadedCount++;
toast.error(`Error uploading ${file.name}: Network error`, 4000);
lt.toast.error(`Error uploading ${file.name}: Network error`, 4000);
if (uploadedCount === totalFiles) {
setTimeout(resetUploadUI, 2000);
}
@@ -864,7 +769,7 @@ function resetUploadUI() {
const progressDiv = document.getElementById('uploadProgress');
const fileInput = document.getElementById('fileInput');
progressDiv.style.display = 'none';
progressDiv.classList.add('is-hidden');
if (fileInput) {
fileInput.value = '';
}
@@ -876,19 +781,16 @@ function loadAttachments() {
if (!container) return;
fetch(`/api/upload_attachment.php?ticket_id=${ticketId}`, {
credentials: 'same-origin'
})
.then(response => response.json())
lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}`)
.then(data => {
if (data.success) {
renderAttachments(data.attachments || []);
} else {
container.innerHTML = '<p style="color: var(--terminal-green-dim);">Error loading attachments.</p>';
container.innerHTML = '<p class="text-muted-green">Error loading attachments.</p>';
}
})
.catch(error => {
container.innerHTML = '<p style="color: var(--terminal-green-dim);">Error loading attachments.</p>';
container.innerHTML = '<p class="text-muted-green">Error loading attachments.</p>';
});
}
@@ -897,7 +799,7 @@ function renderAttachments(attachments) {
if (!container) return;
if (attachments.length === 0) {
container.innerHTML = '<p style="color: var(--terminal-green-dim);">No files attached to this ticket.</p>';
container.innerHTML = '<p class="text-muted-green">No files attached to this ticket.</p>';
return;
}
@@ -905,24 +807,25 @@ function renderAttachments(attachments) {
attachments.forEach(att => {
const uploaderName = att.display_name || att.username || 'Unknown';
const uploadDate = new Date(att.uploaded_at).toLocaleDateString('en-US', {
const uploadDateFormatted = new Date(att.uploaded_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`;
html += `<div class="attachment-item" data-id="${att.attachment_id}">
<div class="attachment-icon">${escapeHtml(att.icon || '📎')}</div>
<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>
<div class="attachment-info">
<div class="attachment-name" title="${escapeHtml(att.original_filename)}">
<a href="/api/download_attachment.php?id=${att.attachment_id}" target="_blank" style="color: var(--terminal-green);">
${escapeHtml(att.original_filename)}
<div class="attachment-name" title="${lt.escHtml(att.original_filename)}">
<a href="/api/download_attachment.php?id=${att.attachment_id}" target="_blank">
${lt.escHtml(att.original_filename)}
</a>
</div>
<div class="attachment-meta">
${escapeHtml(att.file_size_formatted || formatFileSize(att.file_size))}${escapeHtml(uploaderName)}${escapeHtml(uploadDate)}
${lt.escHtml(att.file_size_formatted || formatFileSize(att.file_size))}${lt.escHtml(uploaderName)}${uploadDate}
</div>
</div>
<div class="attachment-actions">
@@ -949,34 +852,25 @@ function formatFileSize(bytes) {
}
function deleteAttachment(attachmentId) {
if (!confirm('Are you sure you want to delete this attachment?')) {
return;
}
fetch('/api/delete_attachment.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
attachment_id: attachmentId,
csrf_token: window.CSRF_TOKEN
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
toast.success('Attachment deleted', 3000);
loadAttachments();
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
showConfirmModal(
'Delete Attachment',
'Are you sure you want to delete this attachment?',
'warning',
function() {
lt.api.post('/api/delete_attachment.php', { attachment_id: attachmentId })
.then(data => {
if (data.success) {
lt.toast.success('Attachment deleted', 3000);
loadAttachments();
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
lt.toast.error('Error deleting attachment', 4000);
});
}
})
.catch(error => {
toast.error('Error deleting attachment', 4000);
});
);
}
// ========================================
@@ -999,7 +893,12 @@ function initMentionAutocomplete() {
mentionAutocomplete = document.createElement('div');
mentionAutocomplete.className = 'mention-autocomplete';
mentionAutocomplete.id = 'mentionAutocomplete';
textarea.parentElement.style.position = 'relative';
mentionAutocomplete.setAttribute('role', 'listbox');
mentionAutocomplete.setAttribute('aria-label', 'User suggestions');
textarea.setAttribute('aria-autocomplete', 'list');
textarea.setAttribute('aria-controls', 'mentionAutocomplete');
textarea.setAttribute('aria-expanded', 'false');
textarea.parentElement.classList.add('has-overlay');
textarea.parentElement.appendChild(mentionAutocomplete);
// Fetch users list
@@ -1096,7 +995,9 @@ function handleMentionKeydown(e) {
*/
function updateMentionSelection(options) {
options.forEach((opt, i) => {
opt.classList.toggle('selected', i === selectedMentionIndex);
const isSelected = i === selectedMentionIndex;
opt.classList.toggle('selected', isSelected);
opt.setAttribute('aria-selected', isSelected ? 'true' : 'false');
});
}
@@ -1118,14 +1019,16 @@ function showMentionSuggestions(query, textarea) {
let html = '';
filtered.forEach((user, index) => {
const isSelected = index === 0 ? 'selected' : '';
html += `<div class="mention-option ${isSelected}" data-username="${escapeHtml(user.username)}" data-action="select-mention">
<span class="mention-username">@${escapeHtml(user.username)}</span>
${user.display_name ? `<span class="mention-displayname">${escapeHtml(user.display_name)}</span>` : ''}
const ariaSelected = index === 0 ? 'true' : 'false';
html += `<div class="mention-option ${isSelected}" role="option" aria-selected="${ariaSelected}" data-username="${lt.escHtml(user.username)}" data-action="select-mention">
<span class="mention-username">@${lt.escHtml(user.username)}</span>
${user.display_name ? `<span class="mention-displayname">${lt.escHtml(user.display_name)}</span>` : ''}
</div>`;
});
mentionAutocomplete.innerHTML = html;
mentionAutocomplete.classList.add('active');
if (textarea) textarea.setAttribute('aria-expanded', 'true');
selectedMentionIndex = 0;
// Position dropdown below cursor
@@ -1140,6 +1043,8 @@ function showMentionSuggestions(query, textarea) {
function hideMentionAutocomplete() {
if (mentionAutocomplete) {
mentionAutocomplete.classList.remove('active');
const textarea = document.getElementById('newComment');
if (textarea) textarea.setAttribute('aria-expanded', 'false');
}
mentionStartPos = -1;
}
@@ -1252,21 +1157,21 @@ function editComment(commentId) {
editForm.className = 'comment-edit-form';
editForm.id = `comment-edit-form-${commentId}`;
editForm.innerHTML = `
<textarea id="comment-edit-textarea-${commentId}" class="comment-edit-textarea">${escapeHtml(originalText)}</textarea>
<textarea id="comment-edit-textarea-${commentId}" class="comment-edit-textarea">${lt.escHtml(originalText)}</textarea>
<div class="comment-edit-controls">
<label class="markdown-toggle-small">
<input type="checkbox" id="comment-edit-markdown-${commentId}" ${markdownEnabled ? 'checked' : ''}>
Markdown
</label>
<div class="comment-edit-buttons">
<button type="button" class="btn btn-small" data-action="save-edit-comment" data-comment-id="${commentId}">Save</button>
<button type="button" class="btn btn-secondary btn-small" data-action="cancel-edit-comment" data-comment-id="${commentId}">Cancel</button>
<button type="button" class="btn btn-small" data-action="save-edit-comment" data-comment-id="${commentId}">SAVE</button>
<button type="button" class="btn btn-secondary btn-small" data-action="cancel-edit-comment" data-comment-id="${commentId}">CANCEL</button>
</div>
</div>
`;
// Hide original text, show edit form
textDiv.style.display = 'none';
textDiv.classList.add('is-hidden');
textDiv.after(editForm);
commentDiv.classList.add('editing');
@@ -1285,27 +1190,18 @@ function saveEditComment(commentId) {
const newText = textarea.value.trim();
if (!newText) {
showToast('Comment cannot be empty', 'error');
lt.toast.error('Comment cannot be empty');
return;
}
const markdownEnabled = markdownCheckbox ? markdownCheckbox.checked : false;
// Send update request
fetch('/api/update_comment.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
comment_id: commentId,
comment_text: newText,
markdown_enabled: markdownEnabled
})
lt.api.post('/api/update_comment.php', {
comment_id: commentId,
comment_text: newText,
markdown_enabled: markdownEnabled
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Update the comment display
@@ -1331,7 +1227,7 @@ function saveEditComment(commentId) {
} else {
textDiv.removeAttribute('data-markdown');
// Convert newlines to <br> and highlight mentions
let displayText = escapeHtml(newText).replace(/\n/g, '<br>');
let displayText = lt.escHtml(newText).replace(/\n/g, '<br>');
displayText = highlightMentions(displayText);
// Auto-link URLs
if (typeof autoLinkUrls === 'function') {
@@ -1342,16 +1238,16 @@ function saveEditComment(commentId) {
// Remove edit form and show text
if (editForm) editForm.remove();
textDiv.style.display = '';
textDiv.classList.remove('is-hidden');
commentDiv.classList.remove('editing');
showToast('Comment updated successfully', 'success');
lt.toast.success('Comment updated successfully');
} else {
showToast(data.error || 'Failed to update comment', 'error');
lt.toast.error(data.error || 'Failed to update comment');
}
})
.catch(error => {
showToast('Failed to update comment', 'error');
lt.toast.error('Failed to update comment');
});
}
@@ -1364,7 +1260,7 @@ function cancelEditComment(commentId) {
const editForm = document.getElementById(`comment-edit-form-${commentId}`);
if (editForm) editForm.remove();
if (textDiv) textDiv.style.display = '';
if (textDiv) textDiv.classList.remove('is-hidden');
if (commentDiv) commentDiv.classList.remove('editing');
}
@@ -1372,40 +1268,29 @@ function cancelEditComment(commentId) {
* Delete a comment
*/
function deleteComment(commentId) {
if (!confirm('Are you sure you want to delete this comment? This cannot be undone.')) {
return;
}
fetch('/api/delete_comment.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
comment_id: commentId
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Remove the comment from the DOM
const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`);
if (commentDiv) {
commentDiv.style.transition = 'opacity 0.3s, transform 0.3s';
commentDiv.style.opacity = '0';
commentDiv.style.transform = 'translateX(-20px)';
setTimeout(() => commentDiv.remove(), 300);
}
showToast('Comment deleted successfully', 'success');
} else {
showToast(data.error || 'Failed to delete comment', 'error');
showConfirmModal(
'Delete Comment',
'Are you sure you want to delete this comment? This cannot be undone.',
'warning',
function() {
lt.api.post('/api/delete_comment.php', { comment_id: commentId })
.then(data => {
if (data.success) {
const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`);
if (commentDiv) {
commentDiv.classList.add('comment--deleting');
setTimeout(() => commentDiv.remove(), 300);
}
lt.toast.success('Comment deleted successfully');
} else {
lt.toast.error(data.error || 'Failed to delete comment');
}
})
.catch(error => {
lt.toast.error('Failed to delete comment');
});
}
})
.catch(error => {
showToast('Failed to delete comment', 'error');
});
);
}
// ========================================
@@ -1426,7 +1311,7 @@ function showReplyForm(commentId, userName) {
<div class="reply-form-container" data-parent-id="${commentId}">
<div class="reply-header">
<span>Replying to <span class="replying-to">@${userName}</span></span>
<button type="button" class="close-reply-btn" data-action="close-reply">Cancel</button>
<button type="button" class="close-reply-btn" data-action="close-reply">CANCEL</button>
</div>
<textarea id="replyText" placeholder="Write your reply..."></textarea>
<div class="reply-actions">
@@ -1435,7 +1320,7 @@ function showReplyForm(commentId, userName) {
<span>Markdown</span>
</label>
<div class="reply-buttons">
<button type="button" class="btn btn-small" data-action="submit-reply" data-parent-id="${commentId}">Reply</button>
<button type="button" class="btn btn-small" data-action="submit-reply" data-parent-id="${commentId}">REPLY</button>
</div>
</div>
</div>
@@ -1457,7 +1342,7 @@ function showReplyForm(commentId, userName) {
*/
function closeReplyForm() {
document.querySelectorAll('.reply-form-container').forEach(form => {
form.style.animation = 'fadeIn 0.2s ease reverse';
form.classList.add('animate-fadeout');
setTimeout(() => form.remove(), 200);
});
}
@@ -1471,28 +1356,19 @@ function submitReply(parentCommentId) {
const ticketId = window.ticketData.id;
if (!replyText || !replyText.value.trim()) {
showToast('Please enter a reply', 'warning');
lt.toast.warning('Please enter a reply');
return;
}
const commentText = replyText.value.trim();
const isMarkdownEnabled = replyMarkdown ? replyMarkdown.checked : false;
fetch('/api/add_comment.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: ticketId,
comment_text: commentText,
markdown_enabled: isMarkdownEnabled,
parent_comment_id: parentCommentId
})
lt.api.post('/api/add_comment.php', {
ticket_id: ticketId,
comment_text: commentText,
markdown_enabled: isMarkdownEnabled,
parent_comment_id: parentCommentId
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Close the reply form
@@ -1543,8 +1419,8 @@ function submitReply(parentCommentId) {
<span class="comment-date">${data.created_at}</span>
<div class="comment-actions">
${newDepth < 3 ? `<button type="button" class="comment-action-btn reply-btn" data-action="reply-comment" data-comment-id="${data.comment_id}" data-user="${data.user_name}" title="Reply">↩</button>` : ''}
<button type="button" class="comment-action-btn edit-btn" data-action="edit-comment" data-comment-id="${data.comment_id}" title="Edit">✏️</button>
<button type="button" class="comment-action-btn delete-btn" data-action="delete-comment" data-comment-id="${data.comment_id}" title="Delete">🗑️</button>
<button type="button" class="comment-action-btn edit-btn" data-action="edit-comment" data-comment-id="${data.comment_id}" title="Edit">[ EDIT ]</button>
<button type="button" class="comment-action-btn delete-btn" data-action="delete-comment" data-comment-id="${data.comment_id}" title="Delete">[ DEL ]</button>
</div>
</div>
<div class="comment-text" id="comment-text-${data.comment_id}" ${isMarkdownEnabled ? 'data-markdown' : ''}>
@@ -1555,17 +1431,17 @@ function submitReply(parentCommentId) {
`;
// Add animation
replyDiv.style.animation = 'fadeIn 0.3s ease';
replyDiv.classList.add('animate-fadein');
repliesContainer.appendChild(replyDiv);
}
showToast('Reply added successfully', 'success');
lt.toast.success('Reply added successfully');
} else {
showToast(data.error || 'Failed to add reply', 'error');
lt.toast.error(data.error || 'Failed to add reply');
}
})
.catch(error => {
showToast('Failed to add reply', 'error');
lt.toast.error('Failed to add reply');
});
}
@@ -1579,6 +1455,19 @@ function toggleThreadCollapse(commentId) {
}
}
// ========================================
// RELATIVE TIMESTAMPS
// ========================================
function initRelativeTimes() {
document.querySelectorAll('.ts-cell[data-ts]').forEach(el => {
el.textContent = lt.time.ago(el.dataset.ts);
});
}
document.addEventListener('DOMContentLoaded', initRelativeTimes);
setInterval(initRelativeTimes, 60000);
// Expose functions globally
window.editComment = editComment;
window.saveEditComment = saveEditComment;

View File

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

View File

@@ -12,11 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<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="/assets/css/base.css">
<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">
<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"></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/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(); ?>';
@@ -25,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">[ &larr; 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>
@@ -48,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>
@@ -62,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 htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?>
<div class="error-message inline-error">
<strong>[ ! ] ERROR:</strong> <?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?>
</div>
</div>
</div>
@@ -90,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>
@@ -109,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>
@@ -185,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>
@@ -206,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';
@@ -220,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>
@@ -258,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>
@@ -277,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;
}
@@ -288,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 => {
@@ -323,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);
}
}
@@ -352,6 +351,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
toggleVisibilityGroups();
}
});
if (window.lt) lt.keys.initDefaults();
</script>
</body>
</html>

View File

@@ -13,13 +13,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<title>Ticket Dashboard</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="/assets/css/base.css">
<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/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"></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>
<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(); ?>';
@@ -33,41 +32,44 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<!-- 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)
@@ -80,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 -->
@@ -163,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>
@@ -180,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>
@@ -203,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>
@@ -252,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'])): ?>
@@ -273,13 +254,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<input type="text"
name="search"
placeholder="🔍 Search tickets..."
placeholder="&gt; 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>
@@ -287,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>
@@ -310,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>
@@ -352,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; ?>
@@ -394,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">&times;</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">&times;</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; ?>
@@ -408,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',
@@ -430,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>";
}
}
?>
@@ -452,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'] . "'>&gt;</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";
@@ -509,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']; ?>">&gt;</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
@@ -541,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">
@@ -578,7 +561,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<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">⚙ System Preferences</span>
<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>
@@ -724,8 +707,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
<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>
<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>
@@ -734,7 +717,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<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">🔍 Advanced Search</span>
<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>
@@ -750,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,17 +824,17 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
<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>
<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();
@@ -879,6 +862,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
openSettingsModal();
break;
case 'manual-refresh':
lt.autoRefresh.now();
break;
case 'close-settings':
closeSettingsModal();
break;
@@ -887,9 +874,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
saveSettings();
break;
case 'toggle-banner':
toggleBanner();
break;
case 'toggle-sidebar':
toggleSidebar();
@@ -1008,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 = '/?';

View File

@@ -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) {
@@ -51,14 +51,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<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="/assets/css/base.css">
<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">
<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/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>
<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(); ?>';
@@ -82,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">[ &larr; 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>
@@ -134,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">
@@ -142,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>";
}
}
?>
@@ -221,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):
@@ -244,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>
@@ -274,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>
@@ -294,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>
@@ -323,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>
@@ -362,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'>[ &lt;&lt; ]</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>";
@@ -422,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>
@@ -463,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>
@@ -498,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">
@@ -558,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';
});
}
});
}
);
});
}
@@ -685,7 +684,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<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">⚙ System Preferences</span>
<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>
@@ -808,14 +807,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
<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>
<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/keyboard-shortcuts.js"></script>
<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>

View File

@@ -13,10 +13,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/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/toast.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>
@@ -24,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>
@@ -61,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>
@@ -98,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>
@@ -149,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>
@@ -187,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);
}
});
@@ -228,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>

View File

@@ -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">
@@ -10,25 +13,28 @@
<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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script src="/assets/js/base.js"></script>
<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>
@@ -36,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>
@@ -51,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' : ''; ?>>
@@ -62,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>
@@ -92,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']);
}
@@ -128,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) {
@@ -155,6 +161,6 @@
</div>
</div>
</div>
<script>if (window.lt) lt.keys.initDefaults();</script>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
</body>
</html>

View File

@@ -13,9 +13,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/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>
@@ -23,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>
@@ -62,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): ?>
@@ -76,29 +76,30 @@ $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="lt-modal-overlay" id="fieldModal" aria-hidden="true">
<div class="lt-modal" style="max-width: 500px;">
<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>✕</button>
<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">
@@ -122,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&#10;Option 2&#10;Option 3"></textarea>
</div>
@@ -149,14 +150,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</div>
<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>
<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';
@@ -208,7 +208,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
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) {
@@ -230,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 {
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;
@@ -276,14 +265,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
}
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>

View File

@@ -13,9 +13,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/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>
@@ -23,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>
@@ -62,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): ?>
@@ -81,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="lt-modal-overlay" id="recurringModal" aria-hidden="true">
<div class="lt-modal" style="max-width: 800px; width: 90%;">
<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>✕</button>
<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="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>
@@ -134,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>
@@ -142,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">
@@ -184,14 +185,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</div>
<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>
<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';
@@ -251,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>`;
}
@@ -271,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 {
lt.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;
@@ -340,8 +324,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
// 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');

View File

@@ -13,9 +13,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/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>
@@ -23,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>
@@ -64,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): ?>
@@ -76,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="lt-modal-overlay" id="templateModal" aria-hidden="true">
<div class="lt-modal" style="max-width: 800px; width: 90%;">
<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>✕</button>
<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="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">
@@ -155,14 +156,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</div>
<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>
<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 ?? []); ?>;
@@ -217,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 {
lt.toast.error(result.error || 'Failed to save');
}
});
}).catch(err => lt.toast.error('Failed to save'));
}
function editTemplate(id) {
@@ -255,14 +245,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
}
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>

View File

@@ -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">
@@ -10,25 +13,28 @@
<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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script src="/assets/js/base.js"></script>
<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>
@@ -36,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): ?>
@@ -74,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>
@@ -97,42 +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>if (window.lt) lt.keys.initDefaults();</script>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
</body>
</html>

View File

@@ -13,9 +13,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/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>
@@ -23,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)) {
@@ -80,7 +81,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
<!-- Transitions Table -->
<table style="width: 100%;">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>From Status</th>
@@ -95,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): ?>
@@ -107,38 +107,39 @@ $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="lt-modal-overlay" id="workflowModal" aria-hidden="true">
<div class="lt-modal" style="max-width: 450px;">
<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>✕</button>
<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">
@@ -172,14 +173,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</div>
<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>
<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 ?? []); ?>;
@@ -232,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 {
lt.toast.error(result.error || 'Failed to save');
}
});
}).catch(err => lt.toast.error('Failed to save'));
}
function editTransition(id) {
@@ -268,14 +258,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
}
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>