Compare commits

...

43 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
e756f8e0bb Fix ascii-frame-outer blink caused by JS/CSS hover conflict
JS mouseenter/mouseleave handlers were setting row.style.backgroundColor
inline, fighting with the CSS tr:hover rule. On mouseleave both fired
simultaneously causing a double repaint / blink. Removed the redundant
JS handlers — the CSS tr:hover transition already handles this cleanly.

Also removed body flicker animation from base.css (was still present
after being removed from dashboard.css).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:14:05 -04:00
fea7575ac8 Fix dashboard blink when cursor leaves ticket table section
- Remove pulse-glow-box animation and translateY from button:hover
  (infinite animation stopping abruptly caused a flash on mouse-leave)
- Scope button transition from 'all' to specific visual properties
  (prevents transform/layout changes from triggering on hover exit)
- Scope th transition from 'all' to background-color + text-shadow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:10:27 -04:00
6fbba3939f Remove page-blinking CSS animations and old deploy script
- Remove .ascii-frame-outer:hover flicker animation (caused article to
  shake/blink every time cursor entered the ticket container)
- Remove body flicker animation (caused full page blink every 30s)
- Remove deploy.sh (deployment now handled by Gitea CI/CD)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:04:03 -04:00
f3c15e2582 Fix table row blink when cursor leaves the table
transition:all was firing on every row simultaneously when the cursor
left the table. Scoped it to background-color only. Also removed the
inset box-shadow from tr:hover which forced repaint layer thrashing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:55:50 -04:00
51fa5a8a3c Add lt.keys.initDefaults() to audit log and user activity views
Ensures ESC/Ctrl+K/? keyboard shortcuts work consistently on all admin pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:04:50 -04:00
4a838b68ca Move base.js/base.css into assets to fix auth proxy 404
/web_template/ path was being intercepted by the auth proxy at
t.lotusguild.org returning HTML instead of the actual files. Moving
base.js and base.css into /assets/js/ and /assets/css/ where static
assets are already served correctly. Updated all 10 view files and
deploy.sh accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:44:46 -04:00
5545328e53 Fix deploy.sh to also sync web_template to server
base.js and base.css were returning 404 because /var/www/html/web_template
did not exist on the server. Now rsyncs /root/code/web_template/ to
/var/www/html/web_template/ before deploying the app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:36:55 -04:00
8bb43c14db Guard lt.* calls when base.js unavailable to prevent crash
Wraps all lt.keys.initDefaults() calls in `if (window.lt)` guards across
6 view files. Adds `if (!window.lt) return` bail-out in keyboard-shortcuts.js
and `if (window.lt)` guard in settings.js DOMContentLoaded handler.

This prevents TypeError crashes when /web_template/base.js returns 404,
which was causing the admin menu click delegation to never register.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:34:59 -04:00
92544d60ce Fix lt-modal-overlay not hidden without base.css
Add lt-modal-overlay, lt-modal, lt-btn fallback styles to dashboard.css
so modals are properly hidden (display:none) and styled even when
/web_template/base.css is not yet served. Mirrors the rules from base.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:29:20 -04:00
89a685a502 Integrate web_template design system and fix security/quality issues
Security fixes:
- Add HTTP method validation to delete_comment.php (block CSRF via GET)
- Remove $_GET fallback in comment deletion (was CSRF bypass vector)
- Guard session_start() with session_status() check across API files
- Escape json_encode() data attributes with htmlspecialchars in views
- Escape inline APP_TIMEZONE config values in DashboardView/TicketView
- Validate timezone param against DateTimeZone::listIdentifiers() in index.php
- Remove Database::escape() (was using real_escape_string, not safe)
- Fix AttachmentModel hardcoded connection; inject via constructor

Backend fixes:
- Fix CommentModel bind_param type for ticket_id (s→i)
- Fix buildCommentThread orphan parent guard
- Fix StatsModel JOIN→LEFT JOIN so unassigned tickets aren't excluded
- Add ticket ID validation in BulkOperationsModel before implode()
- Add duplicate key retry in TicketModel::createTicket() for race conditions
- Wrap SavedFiltersModel default filter changes in transactions
- Add null result guards in WorkflowModel query methods

Frontend JS:
- Rewrite toast.js as lt.toast shim (base.js dependency)
- Delegate escapeHtml() to lt.escHtml()
- Rewrite keyboard-shortcuts.js using lt.keys.on()
- Migrate settings.js to lt.api.* and lt.modal.open/close()
- Migrate advanced-search.js to lt.api.* and lt.modal.open/close()
- Migrate dashboard.js fetch calls to lt.api.*; update all dynamic
  modals (bulk ops, quick actions, confirm/input) to lt-modal structure
- Migrate ticket.js fetchMentionUsers to lt.api.get()
- Remove console.log/error/warn calls from JS files

Views:
- Add /web_template/base.css and base.js to all 10 view files
- Call lt.keys.initDefaults() in DashboardView, TicketView, admin views
- Migrate all modal HTML from settings-modal/settings-content to
  lt-modal-overlay/lt-modal/lt-modal-header/lt-modal-body/lt-modal-footer
- Replace style="display:none" with aria-hidden="true" on all modals
- Replace modal open/close style.display with lt.modal.open/close()
- Update modal buttons to lt-btn lt-btn-primary/lt-btn-ghost classes
- Remove manual ESC keydown handlers (replaced by lt.keys.initDefaults)
- Fix unescaped timezone values in TicketView inline script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:22:24 -04:00
d204756cfe Remove Claude.md (merged into README) and remove aesthetic_diff.md reference
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:49:51 -04:00
a34ca51223 Apply LotusGuild design system convergence (aesthetic_diff.md)
- §10: Filter sidebar labels color green→amber with glow-amber,
  matching unified amber-for-labels convention from base.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:40:43 -04:00
45 changed files with 5205 additions and 2959 deletions

283
Claude.md
View File

@@ -1,283 +0,0 @@
# Tinker Tickets - Project Documentation for AI Assistants
## Project Status (January 2026)
**Current Phase**: All core features implemented. System is production-ready.
**Completed Features**:
- Activity Timeline, Ticket Assignment, Status Transitions with Workflows
- Ticket Templates, Bulk Actions (Admin Only)
- File Attachments, Ticket Dependencies, @Mentions in Comments
- Recurring Tickets, Custom Fields, Advanced Search with Saved Filters
- Export to CSV/JSON, API Key Management
- Ticket Visibility Levels (public/internal/confidential)
- Collapsible Sidebar, Kanban Card View, Inline Ticket Preview
- Mobile Responsive Design, Ticket Linking in Comments
- Admin Pages (Templates, Workflow, Recurring, Custom Fields, User Activity, Audit Log, API Keys)
- Comment Edit/Delete (owner or admin can modify their comments)
- Markdown Tables Support, Auto-linking URLs in Comments
**Security Features** (January 2026):
- CSP with nonce-based script execution (no unsafe-inline)
- IP-based rate limiting (prevents session bypass attacks)
- Visibility checks on attachment downloads
- Unique ticket ID generation with collision prevention
- Internal visibility requires groups validation
## Design Decisions
**Not Planned / Out of Scope**:
- Email integration - Discord webhooks are the notification method for this system
- SLA management - Not required for internal infrastructure use
- Time tracking - Out of scope for current requirements
- OAuth2/External identity providers - Authelia is the only approved SSO method
- GraphQL API - REST API is sufficient for current needs
**Wiki Documentation**: https://wiki.lotusguild.org/en/Services/service-tinker-tickets
## Project Overview
Tinker Tickets is a feature-rich, self-hosted ticket management system built for managing data center infrastructure issues. It features SSO integration with Authelia/LLDAP, workflow management, Discord notifications, and a retro terminal-style web interface.
**Tech Stack:**
- Backend: PHP 7.4+ with MySQLi
- Frontend: Vanilla JavaScript, CSS3
- Database: MariaDB on separate LXC (10.10.10.50)
- Web Server: nginx with PHP-FPM on production (10.10.10.45)
- Authentication: Authelia SSO with LLDAP backend
**Production Environment:**
- **Primary URL**: https://t.lotusguild.org
- **Web Server**: nginx at 10.10.10.45 (`/var/www/html/tinkertickets`)
- **Database**: MariaDB at 10.10.10.50 (`ticketing_system` database)
- **Authentication**: Authelia provides SSO via headers
- **Dev Environment**: `/root/code/tinker_tickets` (not production)
## Architecture
### MVC Pattern
```
Controllers → Models → Database
Views
```
### Project Structure
```
/tinker_tickets/
├── api/ # API endpoints
│ ├── add_comment.php # POST: Add comment
│ ├── assign_ticket.php # POST: Assign ticket to user
│ ├── bulk_operation.php # POST: Bulk operations - admin only
│ ├── check_duplicates.php # GET: Check for duplicate tickets
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
│ ├── download_attachment.php # GET: Download attachment (with visibility check)
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
│ ├── generate_api_key.php # POST: Generate API key (admin)
│ ├── get_template.php # GET: Fetch ticket template
│ ├── get_users.php # GET: Get user list
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
│ ├── manage_templates.php # CRUD: Templates (admin)
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
│ ├── update_comment.php # POST: Update comment (owner/admin)
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
│ └── upload_attachment.php # GET/POST: List or upload attachments
├── assets/
│ ├── css/
│ │ ├── dashboard.css # Dashboard + terminal styling
│ │ └── ticket.css # Ticket view styling
│ ├── js/
│ │ ├── advanced-search.js # Advanced search modal
│ │ ├── ascii-banner.js # ASCII art banner
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts
│ │ ├── markdown.js # Markdown rendering + ticket linking
│ │ ├── settings.js # User preferences
│ │ ├── ticket.js # Ticket + comments + visibility
│ │ └── toast.js # Toast notifications
│ └── images/
│ └── favicon.png
├── config/
│ └── config.php # Config + .env loading
├── controllers/
│ ├── DashboardController.php # Dashboard with stats + filters
│ └── TicketController.php # Ticket CRUD + timeline + visibility
├── cron/
│ └── create_recurring_tickets.php # Process recurring ticket schedules
├── helpers/
│ └── ResponseHelper.php # Standardized JSON responses
├── middleware/
│ ├── AuthMiddleware.php # Authelia SSO integration
│ ├── CsrfMiddleware.php # CSRF protection
│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
│ └── SecurityHeadersMiddleware.php # CSP with nonces, security headers
├── models/
│ ├── ApiKeyModel.php # API key generation/validation
│ ├── AuditLogModel.php # Audit logging + timeline
│ ├── BulkOperationsModel.php # Bulk operations tracking
│ ├── CommentModel.php # Comment data access
│ ├── CustomFieldModel.php # Custom field definitions/values
│ ├── DependencyModel.php # Ticket dependencies
│ ├── RecurringTicketModel.php # Recurring ticket schedules
│ ├── StatsModel.php # Dashboard statistics
│ ├── TemplateModel.php # Ticket templates
│ ├── TicketModel.php # Ticket CRUD + assignment + visibility
│ ├── UserModel.php # User management + groups
│ ├── UserPreferencesModel.php # User preferences
│ └── WorkflowModel.php # Status transition workflows
├── scripts/
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
│ └── create_dependencies_table.php # Create ticket_dependencies table
├── uploads/ # File attachment storage
├── views/
│ ├── admin/
│ │ ├── ApiKeysView.php # API key management
│ │ ├── AuditLogView.php # Audit log browser
│ │ ├── CustomFieldsView.php # Custom field management
│ │ ├── RecurringTicketsView.php # Recurring ticket management
│ │ ├── TemplatesView.php # Template management
│ │ ├── UserActivityView.php # User activity report
│ │ └── WorkflowDesignerView.php # Workflow transition designer
│ ├── CreateTicketView.php # Ticket creation with visibility
│ ├── DashboardView.php # Dashboard with kanban + sidebar
│ └── TicketView.php # Ticket view with visibility editing
├── .env # Environment variables (GITIGNORED)
├── Claude.md # This file
├── README.md # User documentation
└── index.php # Main router
```
## Admin Pages
All admin pages are accessible via the **Admin dropdown** in the dashboard header (for admin users only).
| Route | Description |
|-------|-------------|
| `/admin/templates` | Create and edit ticket templates |
| `/admin/workflow` | Visual workflow transition designer |
| `/admin/recurring-tickets` | Manage recurring ticket schedules |
| `/admin/custom-fields` | Define custom fields per category |
| `/admin/user-activity` | View per-user activity statistics |
| `/admin/audit-log` | Browse all audit log entries |
| `/admin/api-keys` | Generate and manage API keys |
## Database Schema
**Database**: `ticketing_system` at 10.10.10.50
**User**: `tinkertickets`
### Core Tables
| Table | Description |
|-------|-------------|
| `tickets` | Core ticket data with assignment, visibility, and tracking |
| `ticket_comments` | Markdown-supported comments with user_id reference |
| `ticket_attachments` | File attachment metadata |
| `ticket_dependencies` | Ticket relationships (blocks, blocked_by, relates_to, duplicates) |
| `users` | User accounts synced from LLDAP (includes groups) |
| `user_preferences` | User settings and preferences |
| `audit_log` | Complete audit trail with indexed queries |
| `status_transitions` | Workflow configuration |
| `ticket_templates` | Reusable ticket templates |
| `recurring_tickets` | Scheduled ticket definitions |
| `custom_field_definitions` | Custom field schemas per category |
| `custom_field_values` | Custom field data per ticket |
| `saved_filters` | User-saved dashboard filters |
| `bulk_operations` | Bulk operation tracking |
| `api_keys` | API key storage with hashed keys |
### tickets Table Key Columns
| Column | Type | Description |
|--------|------|-------------|
| `ticket_id` | varchar(9) | Unique 9-digit identifier |
| `visibility` | enum | 'public', 'internal', 'confidential' |
| `visibility_groups` | varchar(500) | Comma-separated group names (for internal) |
| `created_by` | int | Foreign key to users |
| `assigned_to` | int | Foreign key to users (nullable) |
| `updated_by` | int | Foreign key to users |
| `priority` | int | 1-5 (1=Critical, 5=Minimal) |
| `status` | varchar(20) | Open, Pending, In Progress, Closed |
### Indexed Columns (for performance)
- `tickets`: ticket_id (unique), status, priority, created_at, created_by, assigned_to, visibility
- `audit_log`: user_id, action_type, entity_type, created_at
## Dashboard Features
- **View Toggle**: Switch between Table view and Kanban card view
- **Collapsible Sidebar**: Click arrow to collapse/expand filter sidebar
- **Stats Widgets**: Clickable cards for quick filtering
- **Inline Ticket Preview**: Hover over ticket IDs for 300ms to see preview popup
- **Sortable Columns**: Click headers to sort
- **Advanced Search**: Date ranges, priority ranges, user filters
- **Saved Filters**: Save and load custom filter combinations
- **Bulk Actions** (admin): Select multiple tickets for bulk operations
- **Export**: Export selected tickets to CSV or JSON
## Ticket Visibility Levels
- **Public**: All authenticated users can view
- **Internal**: Only users in specified groups can view (groups required)
- **Confidential**: Only creator, assignee, and admins can view
**Important**: Internal visibility requires at least one group to be specified. Attempting to create/update a ticket with internal visibility but no groups will fail validation.
## Important Notes for AI Assistants
1. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
2. **API auth**: Check `$_SESSION['user']['user_id']` exists
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
4. **Config path**: `config/config.php` (not `config/db.php`)
5. **Comments table**: `ticket_comments` (not `comments`)
6. **CSRF**: Required for POST/DELETE requests via `X-CSRF-Token` header
7. **Cache busting**: Use `?v=YYYYMMDD` query params on JS/CSS files
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
9. **User groups**: Stored in `users.groups` as comma-separated values
10. **API routing**: All API endpoints must be added to `index.php` router
11. **Session in APIs**: RateLimitMiddleware starts session; don't call session_start() again
12. **Database collation**: Use `utf8mb4_general_ci` (not unicode_ci) for new tables
13. **Discord webhook URLs**: Set `APP_DOMAIN` in `.env` for correct ticket URLs in Discord notifications
13. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
14. **CSP Nonces**: All inline scripts require `nonce="<?php echo $nonce; ?>"` attribute
15. **Visibility validation**: Internal visibility requires groups; code validates this
16. **Rate limiting**: Both session-based AND IP-based limits are enforced
## File Reference Quick Guide
| File | Purpose |
|------|---------|
| `index.php` | Main router for all routes |
| `api/update_ticket.php` | Ticket updates with workflow + visibility |
| `api/download_attachment.php` | File downloads with visibility check |
| `api/bulk_operation.php` | Bulk operations with visibility filtering |
| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation |
| `models/ApiKeyModel.php` | API key generation and validation |
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with nonce generation |
| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting |
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions |
| `assets/js/ticket.js` | Ticket UI, visibility editing |
| `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) |
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar |
## Security Implementations
| Feature | Implementation |
|---------|---------------|
| SQL Injection | All queries use prepared statements with parameter binding |
| XSS Prevention | HTML escaped in markdown parser, CSP with nonces |
| CSRF Protection | Token-based with constant-time comparison |
| Session Security | Fixation prevention, secure cookies, timeout |
| Rate Limiting | Session-based + IP-based (file storage) |
| File Security | Path traversal prevention, MIME validation |
| Visibility | Enforced on views, downloads, and bulk operations |
## Repository
- **Gitea**: https://code.lotusguild.org/LotusGuild/tinker_tickets
- **Production**: https://t.lotusguild.org
- **Wiki**: https://wiki.lotusguild.org/en/Services/service-tinker-tickets

320
README.md
View File

@@ -3,6 +3,22 @@
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic. A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets) **Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
## Styling & Layout
Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
- [`web_template/README.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/README.md) — full component reference, CSS variables, JS API
- [`web_template/base.css`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.css) — unified CSS (`.lt-*` classes)
- [`web_template/base.js`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.js) — `window.lt` utilities (toast, modal, CSRF, fetch helpers)
- [`web_template/php/layout.php`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/php/layout.php) — PHP base layout template
**Key conventions:**
- All `.lt-*` CSS classes come from `base.css` — do not duplicate them in `assets/css/`
- All `lt.*` JS utilities come from `base.js` — use `lt.toast`, `lt.modal`, `lt.api`, etc.
- CSP nonces: every `<script>` tag needs `nonce="<?php echo $nonce; ?>"`
- CSRF: inject `window.CSRF_TOKEN` via the nonce-protected inline script block; `lt.api.*` adds the header automatically
## Design Decisions ## Design Decisions
@@ -17,7 +33,7 @@ The following features are intentionally **not planned** for this system:
### Dashboard & Ticket Management ### Dashboard & Ticket Management
- **View Modes**: Toggle between Table view and Kanban card view - **View Modes**: Toggle between Table view and Kanban card view
- **Collapsible Sidebar**: Click the arrow to collapse/expand the filter sidebar - **Collapsible Sidebar**: Click the arrow to collapse/expand the filter sidebar
- **Inline Ticket Preview**: Hover over ticket IDs for a quick preview popup - **Inline Ticket Preview**: Hover over ticket IDs for a quick preview popup (300ms delay)
- **Stats Widgets**: Clickable cards for quick filtering (Open, Critical, Unassigned, Today's tickets) - **Stats Widgets**: Clickable cards for quick filtering (Open, Critical, Unassigned, Today's tickets)
- **Full-Text Search**: Search across tickets, descriptions, and metadata - **Full-Text Search**: Search across tickets, descriptions, and metadata
- **Advanced Search**: Date ranges, priority ranges, user filters with saved filter support - **Advanced Search**: Date ranges, priority ranges, user filters with saved filter support
@@ -30,7 +46,7 @@ The following features are intentionally **not planned** for this system:
### Ticket Visibility Levels ### Ticket Visibility Levels
- **Public**: All authenticated users can view the ticket - **Public**: All authenticated users can view the ticket
- **Internal**: Only users in specified groups can view the ticket - **Internal**: Only users in specified groups can view the ticket (at least one group required)
- **Confidential**: Only the creator, assignee, and admins can view the ticket - **Confidential**: Only the creator, assignee, and admins can view the ticket
### Workflow Management ### Workflow Management
@@ -102,7 +118,7 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
### Notifications ### Notifications
- **Discord Integration**: Webhook notifications for ticket creation and updates - **Discord Integration**: Webhook notifications for ticket creation and updates
- **Rich Embeds**: Color-coded priority indicators and ticket links - **Rich Embeds**: Color-coded priority indicators and ticket links
- **Dynamic URLs**: Ticket links adapt to the server hostname - **Dynamic URLs**: Ticket links adapt to the server hostname (set `APP_DOMAIN` in `.env`)
### Keyboard Shortcuts ### Keyboard Shortcuts
| Shortcut | Action | | Shortcut | Action |
@@ -110,6 +126,11 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `Ctrl/Cmd + E` | Toggle edit mode (ticket page) | | `Ctrl/Cmd + E` | Toggle edit mode (ticket page) |
| `Ctrl/Cmd + S` | Save changes (ticket page) | | `Ctrl/Cmd + S` | Save changes (ticket page) |
| `Ctrl/Cmd + K` | Focus search box (dashboard) | | `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 | | `ESC` | Cancel edit / close modal |
| `?` | Show keyboard shortcuts help | | `?` | Show keyboard shortcuts help |
@@ -129,6 +150,7 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
- **Language**: PHP 7.4+ - **Language**: PHP 7.4+
- **Database**: MariaDB/MySQL - **Database**: MariaDB/MySQL
- **Architecture**: MVC pattern with models, views, controllers - **Architecture**: MVC pattern with models, views, controllers
- **Authentication**: Authelia SSO with LLDAP backend
### Frontend ### Frontend
- **HTML5/CSS3**: Semantic markup with retro terminal styling - **HTML5/CSS3**: Semantic markup with retro terminal styling
@@ -138,6 +160,7 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
- **Mobile Responsive**: Touch-friendly controls, responsive layouts - **Mobile Responsive**: Touch-friendly controls, responsive layouts
### Database Tables ### Database Tables
| Table | Purpose | | Table | Purpose |
|-------|---------| |-------|---------|
| `tickets` | Core ticket data with visibility | | `tickets` | Core ticket data with visibility |
@@ -153,9 +176,29 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `custom_field_definitions` | Custom field schemas | | `custom_field_definitions` | Custom field schemas |
| `custom_field_values` | Custom field data | | `custom_field_values` | Custom field data |
| `saved_filters` | Saved filter combinations | | `saved_filters` | Saved filter combinations |
| `api_keys` | API key storage | | `bulk_operations` | Bulk operation tracking |
| `api_keys` | API key storage with hashed keys |
#### `tickets` Table Key Columns
| Column | Type | Description |
|--------|------|-------------|
| `ticket_id` | varchar(9) | Unique 9-digit identifier |
| `visibility` | enum | `public`, `internal`, `confidential` |
| `visibility_groups` | varchar(500) | Comma-separated group names (for internal) |
| `created_by` | int | Foreign key to users |
| `assigned_to` | int | Foreign key to users (nullable) |
| `updated_by` | int | Foreign key to users |
| `priority` | int | 15 (1=Critical, 5=Minimal) |
| `status` | varchar(20) | Open, Pending, In Progress, Closed |
#### Indexed Columns (performance)
- `tickets`: `ticket_id` (unique), `status`, `priority`, `created_at`, `created_by`, `assigned_to`, `visibility`
- `audit_log`: `user_id`, `action_type`, `entity_type`, `created_at`
### API Endpoints ### API Endpoints
| Endpoint | Method | Description | | Endpoint | Method | Description |
|----------|--------|-------------| |----------|--------|-------------|
| `/api/update_ticket.php` | POST | Update ticket with workflow validation | | `/api/update_ticket.php` | POST | Update ticket with workflow validation |
@@ -169,75 +212,104 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `/api/export_tickets.php` | GET | Export tickets to CSV/JSON | | `/api/export_tickets.php` | GET | Export tickets to CSV/JSON |
| `/api/generate_api_key.php` | POST | Generate API key (admin) | | `/api/generate_api_key.php` | POST | Generate API key (admin) |
| `/api/revoke_api_key.php` | POST | Revoke API key (admin) | | `/api/revoke_api_key.php` | POST | Revoke API key (admin) |
| `/api/delete_comment.php` | POST | Delete comment (owner/admin) |
## Setup & Configuration | `/api/update_comment.php` | POST | Update comment (owner/admin) |
| `/api/delete_attachment.php` | POST/DELETE | Delete attachment |
### 1. Environment Configuration | `/api/download_attachment.php` | GET | Download attachment (visibility checked) |
| `/api/check_duplicates.php` | GET | Check for duplicate tickets |
Copy the example file and edit with your values: | `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) |
```bash | `/api/manage_templates.php` | CRUD | Templates (admin) |
cp .env.example .env | `/api/manage_workflows.php` | CRUD | Workflow rules (admin) |
nano .env
```
Required environment variables:
```env
DB_HOST=10.10.10.50
DB_USER=tinkertickets
DB_PASS=your_password
DB_NAME=ticketing_system
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
APP_DOMAIN=t.lotusguild.org
TIMEZONE=America/New_York
```
**Note**: `APP_DOMAIN` is required for Discord webhook ticket links to work correctly. Without it, links will default to localhost.
### 2. Cron Jobs
Add to crontab for recurring tickets:
```bash
# Run every hour to create scheduled recurring tickets
0 * * * * php /var/www/html/tinkertickets/cron/create_recurring_tickets.php
```
### 3. File Uploads
Ensure the `uploads/` directory exists and is writable:
```bash
mkdir -p /var/www/html/tinkertickets/uploads
chown www-data:www-data /var/www/html/tinkertickets/uploads
chmod 755 /var/www/html/tinkertickets/uploads
```
### 4. Authelia Integration
Tinker Tickets uses Authelia for SSO. User information is passed via headers:
- `Remote-User`: Username
- `Remote-Name`: Display name
- `Remote-Email`: Email address
- `Remote-Groups`: User groups (comma-separated)
Admin users must be in the `admin` group in LLDAP.
## Project Structure ## Project Structure
``` ```
tinker_tickets/ tinker_tickets/
├── api/ # API endpoints ├── api/
├── assets/ # Static assets (CSS, JS) ├── add_comment.php # POST: Add comment
├── config/ # Configuration │ ├── assign_ticket.php # POST: Assign ticket to user
├── controllers/ # MVC Controllers │ ├── bulk_operation.php # POST: Bulk operations (admin only)
├── cron/ # Scheduled task scripts ├── check_duplicates.php # GET: Check for duplicate tickets
├── helpers/ # Utility classes │ ├── delete_attachment.php # POST/DELETE: Delete attachment
├── middleware/ # Request middleware │ ├── delete_comment.php # POST: Delete comment (owner/admin)
├── models/ # Data models │ ├── download_attachment.php # GET: Download with visibility check
├── scripts/ # Maintenance scripts │ ├── export_tickets.php # GET: Export tickets to CSV/JSON
├── uploads/ # File upload storage │ ├── generate_api_key.php # POST: Generate API key (admin)
├── views/ # View templates │ ├── get_template.php # GET: Fetch ticket template
── admin/ # Admin panel views ── get_users.php # GET: Get user list
├── index.php # Main router │ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
└── .env # Environment configuration │ ├── manage_templates.php # CRUD: Templates (admin)
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
│ ├── update_comment.php # POST: Update comment (owner/admin)
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
│ └── upload_attachment.php # GET/POST: List or upload attachments
├── assets/
│ ├── css/
│ │ ├── base.css # LotusGuild Terminal Design System (symlinked from web_template)
│ │ ├── dashboard.css # Dashboard + terminal styling
│ │ └── ticket.css # Ticket view styling
│ ├── js/
│ │ ├── advanced-search.js # Advanced search modal
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
│ │ ├── base.js # LotusGuild JS utilities — window.lt (symlinked from web_template)
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
│ │ ├── settings.js # User preferences
│ │ ├── ticket.js # Ticket + comments + visibility
│ │ ├── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
│ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
│ └── images/
│ └── favicon.png
├── config/
│ └── config.php # Config + .env loading
├── controllers/
│ ├── DashboardController.php # Dashboard with stats + filters
│ └── TicketController.php # Ticket CRUD + timeline + visibility
├── cron/
│ └── create_recurring_tickets.php # Process recurring ticket schedules
├── helpers/
│ └── ResponseHelper.php # Standardized JSON responses
├── middleware/
│ ├── AuthMiddleware.php # Authelia SSO integration
│ ├── CsrfMiddleware.php # CSRF protection
│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
│ └── SecurityHeadersMiddleware.php # CSP with nonces, security headers
├── models/
│ ├── ApiKeyModel.php # API key generation/validation
│ ├── AuditLogModel.php # Audit logging + timeline
│ ├── BulkOperationsModel.php # Bulk operations tracking
│ ├── CommentModel.php # Comment data access
│ ├── CustomFieldModel.php # Custom field definitions/values
│ ├── DependencyModel.php # Ticket dependencies
│ ├── RecurringTicketModel.php # Recurring ticket schedules
│ ├── StatsModel.php # Dashboard statistics
│ ├── TemplateModel.php # Ticket templates
│ ├── TicketModel.php # Ticket CRUD + visibility + collision-safe IDs
│ ├── UserModel.php # User management + groups
│ ├── UserPreferencesModel.php # User preferences
│ └── WorkflowModel.php # Status transition workflows
├── scripts/
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
│ └── create_dependencies_table.php # Create ticket_dependencies table
├── uploads/ # File attachment storage
├── views/
│ ├── admin/
│ │ ├── ApiKeysView.php # API key management
│ │ ├── AuditLogView.php # Audit log browser
│ │ ├── CustomFieldsView.php # Custom field management
│ │ ├── RecurringTicketsView.php # Recurring ticket management
│ │ ├── TemplatesView.php # Template management
│ │ ├── UserActivityView.php # User activity report
│ │ └── WorkflowDesignerView.php # Workflow transition designer
│ ├── CreateTicketView.php # Ticket creation with visibility
│ ├── DashboardView.php # Dashboard with kanban + sidebar
│ └── TicketView.php # Ticket view with visibility editing
├── .env # Environment variables (GITIGNORED)
├── README.md # This file
└── index.php # Main router
``` ```
## Workflow States ## Workflow States
@@ -252,6 +324,118 @@ Open → Pending → In Progress → Closed
All states can transition to Closed (with comment). All states can transition to Closed (with comment).
Closed tickets can be reopened to Open or In Progress. Closed tickets can be reopened to Open or In Progress.
## Setup & Configuration
### 1. Environment Configuration
Copy the example file and edit with your values:
```bash
cp .env.example .env
nano .env
```
Required environment variables:
```env
DB_HOST=your_db_host
DB_USER=your_db_user
DB_PASS=your_password
DB_NAME=ticketing_system
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
APP_DOMAIN=your.domain.example
TIMEZONE=America/New_York
```
**Note**: `APP_DOMAIN` is required for Discord webhook ticket links to work correctly. Without it, links will default to localhost.
### 2. Cron Jobs
Add to crontab for recurring tickets:
```bash
# Run every hour to create scheduled recurring tickets
0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php
```
### 3. File Uploads
Ensure the `uploads/` directory exists and is writable:
```bash
mkdir -p /path/to/tinkertickets/uploads
chown www-data:www-data /path/to/tinkertickets/uploads
chmod 755 /path/to/tinkertickets/uploads
```
### 4. Authelia Integration
Tinker Tickets uses Authelia for SSO. User information is passed via headers:
- `Remote-User`: Username
- `Remote-Name`: Display name
- `Remote-Email`: Email address
- `Remote-Groups`: User groups (comma-separated)
Admin users must be in the `admin` group in LLDAP.
## Developer Notes
Key conventions and gotchas for working with this codebase:
1. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
2. **API auth check**: Verify `$_SESSION['user']['user_id']` exists
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
4. **Config path**: `config/config.php` (not `config/db.php`)
5. **Comments table**: `ticket_comments` (not `comments`)
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header
7. **Cache busting**: Use `?v=YYYYMMDD` query params on JS/CSS files
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
9. **User groups**: Stored in `users.groups` as comma-separated values
10. **API routing**: All API endpoints must be registered in `index.php` router
11. **Session in APIs**: `RateLimitMiddleware` starts the session — do not call `session_start()` again
12. **Database collation**: Use `utf8mb4_general_ci` (not `unicode_ci`) for new tables
13. **Discord URLs**: Set `APP_DOMAIN` in `.env` for correct ticket URLs in Discord notifications
14. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
15. **CSP nonces**: All inline `<script>` tags require `nonce="<?php echo $nonce; ?>"`
16. **Visibility validation**: Internal visibility requires at least one group — validated server-side
17. **Rate limiting**: Both session-based AND IP-based limits are enforced
18. **Relative timestamps**: Dashboard dates use `lt.time.ago()` and refresh every 60s; full date is always in the `title` attribute for hover
19. **Boot sequence**: Shows ASCII banner then boot messages on first page visit per session (`sessionStorage.getItem('booted')`); removed on subsequent loads
20. **No raw `fetch()`**: All AJAX calls use `lt.api.get/post/put/delete()` — never use raw `fetch()`. The wrapper auto-adds `Content-Type: application/json` and `X-CSRF-Token`, auto-parses JSON, and throws on non-2xx.
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
24. **CSS utility classes** (dashboard.css): Use `.text-green`, `.text-amber`, `.text-cyan`, `.text-danger`, `.text-open`, `.text-closed`, `.text-muted`, `.text-muted-green`, `.text-sm`, `.text-center`, `.nowrap`, `.mono`, `.fw-bold`, `.mb-1` for typography. Use `.admin-container` (1200px) or `.admin-container-wide` (1400px) for admin page max-widths. Use `.admin-header-row` for title+button header rows. Use `.admin-form-row`, `.admin-form-field`, `.admin-label`, `.admin-input` for filter forms. Use `.admin-form-actions` for filter form button groups. Use `.table-wrapper` + `td.empty-state` for tables. Use `.setting-grid-2` and `.setting-grid-3` for modal form grids. Use `.lt-modal-sm` or `.lt-modal-lg` for modal sizing.
25. **CSS utility classes** (ticket.css): Use `.form-hint` (green helper text), `.form-hint-warning` (amber helper text), `.visibility-groups-list` (checkbox row), `.group-checkbox-label` (checkbox label) for ticket forms.
## File Reference
| File | Purpose |
|------|---------|
| `index.php` | Main router for all routes |
| `config/config.php` | Config loader + .env parsing |
| `api/update_ticket.php` | Ticket updates with workflow + visibility validation |
| `api/download_attachment.php` | File downloads with visibility check |
| `api/bulk_operation.php` | Bulk operations with visibility filtering |
| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation |
| `models/ApiKeyModel.php` | API key generation and validation |
| `middleware/AuthMiddleware.php` | Authelia header parsing + session setup |
| `middleware/CsrfMiddleware.php` | CSRF token generation and validation |
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with per-request nonce generation |
| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting |
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions |
| `assets/js/ticket.js` | Ticket UI, visibility editing |
| `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) |
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar |
## Security Implementations
| Feature | Implementation |
|---------|---------------|
| SQL Injection | All queries use prepared statements with parameter binding |
| XSS Prevention | HTML escaped in markdown parser; CSP with per-request nonces |
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`) |
| Session Security | Fixation prevention, secure cookies, session timeout |
| Rate Limiting | Session-based + IP-based (file storage) |
| File Security | Path traversal prevention, MIME type validation |
| Visibility | Enforced on ticket views, downloads, and bulk operations |
## License ## License
Internal use only - LotusGuild Infrastructure Internal use only - LotusGuild Infrastructure

View File

@@ -30,7 +30,9 @@ try {
require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/helpers/Database.php';
// Check authentication via session // Check authentication via session
session_start(); if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required"); throw new Exception("Authentication required");
} }
@@ -60,7 +62,14 @@ try {
throw new Exception("Invalid JSON data received"); 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 // Initialize models
$commentModel = new CommentModel($conn); $commentModel = new CommentModel($conn);

View File

@@ -6,10 +6,17 @@ require_once dirname(__DIR__) . '/models/UserModel.php';
// Get request data // Get request data
$data = json_decode(file_get_contents('php://input'), true); $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; $assignedTo = $data['assigned_to'] ?? null;
if (!$ticketId) { if (!$ticketId) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Ticket ID required']); echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
exit; exit;
} }
@@ -18,6 +25,21 @@ $ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn); $auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($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 === '') { if ($assignedTo === null || $assignedTo === '') {
// Unassign ticket // Unassign ticket
$success = $ticketModel->unassignTicket($ticketId, $userId); $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 // Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Not authenticated']); echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit; exit;
} }
@@ -32,6 +33,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Check admin status - bulk operations are admin-only // Check admin status - bulk operations are admin-only
$isAdmin = $_SESSION['user']['is_admin'] ?? false; $isAdmin = $_SESSION['user']['is_admin'] ?? false;
if (!$isAdmin) { if (!$isAdmin) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin access required']); echo json_encode(['success' => false, 'error' => 'Admin access required']);
exit; exit;
} }

View File

@@ -17,7 +17,9 @@ try {
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication // Check authentication
session_start(); if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401); http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']); echo json_encode(['success' => false, 'error' => 'Authentication required']);
@@ -50,8 +52,14 @@ try {
exit; 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']; $userId = $_SESSION['user']['user_id'];
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
// Get database connection // Get database connection
$conn = Database::getConnection(); $conn = Database::getConnection();
@@ -66,6 +74,15 @@ try {
exit; 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 // Prepare cloned ticket data
$clonedTicketData = [ $clonedTicketData = [
'title' => '[CLONE] ' . $sourceTicket['title'], 'title' => '[CLONE] ' . $sourceTicket['title'],

View File

@@ -58,7 +58,7 @@ if (!$attachmentId || !is_numeric($attachmentId)) {
$attachmentId = (int)$attachmentId; $attachmentId = (int)$attachmentId;
try { try {
$attachmentModel = new AttachmentModel(); $attachmentModel = new AttachmentModel(Database::getConnection());
// Get attachment details // Get attachment details
$attachment = $attachmentModel->getAttachment($attachmentId); $attachment = $attachmentModel->getAttachment($attachmentId);

View File

@@ -21,8 +21,19 @@ try {
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Only allow POST or DELETE — reject GET to prevent CSRF bypass
$method = $_SERVER['REQUEST_METHOD'];
if ($method !== 'POST' && $method !== 'DELETE') {
http_response_code(405);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
// Check authentication via session // Check authentication via session
session_start(); if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required"); throw new Exception("Authentication required");
} }
@@ -48,9 +59,9 @@ try {
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
if (!$data || !isset($data['comment_id'])) { if (!$data || !isset($data['comment_id'])) {
// Try query params // Also check POST params (no GET fallback — prevents CSRF bypass via URL)
if (isset($_GET['comment_id'])) { if (isset($_POST['comment_id'])) {
$data = ['comment_id' => $_GET['comment_id']]; $data = ['comment_id' => $_POST['comment_id']];
} else { } else {
throw new Exception("Missing required field: comment_id"); throw new Exception("Missing required field: comment_id");
} }

View File

@@ -33,7 +33,7 @@ if (!$attachmentId || !is_numeric($attachmentId)) {
$attachmentId = (int)$attachmentId; $attachmentId = (int)$attachmentId;
try { try {
$attachmentModel = new AttachmentModel(); $attachmentModel = new AttachmentModel(Database::getConnection());
// Get attachment details // Get attachment details
$attachment = $attachmentModel->getAttachment($attachmentId); $attachment = $attachmentModel->getAttachment($attachmentId);

View File

@@ -22,7 +22,9 @@ try {
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session // Check authentication via session
session_start(); if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required"); throw new Exception("Authentication required");
} }

View File

@@ -28,7 +28,9 @@ try {
require_once $workflowModelPath; require_once $workflowModelPath;
// Check authentication via session // Check authentication via session
session_start(); if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required"); throw new Exception("Authentication required");
} }
@@ -77,6 +79,17 @@ try {
]; ];
} }
// Authorization: admins can edit any ticket; others only their own or assigned
if (!$this->isAdmin
&& $currentTicket['created_by'] != $this->userId
&& $currentTicket['assigned_to'] != $this->userId
) {
return [
'success' => false,
'error' => 'Permission denied'
];
}
// Merge current data with updates, keeping existing values for missing fields // Merge current data with updates, keeping existing values for missing fields
$updateData = [ $updateData = [
'ticket_id' => $id, 'ticket_id' => $id,

View File

@@ -46,7 +46,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
} }
try { try {
$attachmentModel = new AttachmentModel(); $attachmentModel = new AttachmentModel(Database::getConnection());
$attachments = $attachmentModel->getAttachments($ticketId); $attachments = $attachmentModel->getAttachments($ticketId);
// Add formatted file size and icon to each attachment // Add formatted file size and icon to each attachment
@@ -155,7 +155,7 @@ if (empty($originalFilename)) {
// Save to database // Save to database
try { try {
$attachmentModel = new AttachmentModel(); $attachmentModel = new AttachmentModel($conn);
$attachmentId = $attachmentModel->addAttachment( $attachmentId = $attachmentModel->addAttachment(
$ticketId, $ticketId,
$uniqueFilename, $uniqueFilename,

1701
assets/css/base.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -262,12 +262,11 @@
color: var(--terminal-green); color: var(--terminal-green);
font-family: var(--font-mono); font-family: var(--font-mono);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: border-color 0.2s ease;
} }
.metadata-select:hover { .metadata-select:hover {
border-color: var(--terminal-amber); border-color: var(--terminal-amber);
box-shadow: var(--glow-amber);
} }
.metadata-select:focus { .metadata-select:focus {
@@ -346,24 +345,28 @@ textarea[data-field="description"]:not(:disabled)::after {
color: var(--terminal-amber); color: var(--terminal-amber);
border-color: var(--terminal-amber); border-color: var(--terminal-amber);
background: rgba(255, 176, 0, 0.1); 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; animation: pulse-warning 2s ease-in-out infinite;
will-change: opacity;
} }
.ticket-age.age-critical { .ticket-age.age-critical {
color: var(--priority-1); color: var(--priority-1);
border-color: var(--priority-1); border-color: var(--priority-1);
background: rgba(255, 77, 77, 0.15); 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; animation: pulse-critical 1s ease-in-out infinite;
will-change: opacity;
} }
@keyframes pulse-warning { @keyframes pulse-warning {
0%, 100% { box-shadow: 0 0 5px rgba(255, 176, 0, 0.3); } 0%, 100% { opacity: 0.75; }
50% { box-shadow: 0 0 15px rgba(255, 176, 0, 0.6); } 50% { opacity: 1; }
} }
@keyframes pulse-critical { @keyframes pulse-critical {
0%, 100% { box-shadow: 0 0 5px rgba(255, 77, 77, 0.3); } 0%, 100% { opacity: 0.7; }
50% { box-shadow: 0 0 20px rgba(255, 77, 77, 0.8); } 50% { opacity: 1; }
} }
/* Tab transition animations */ /* Tab transition animations */
@@ -463,6 +466,50 @@ textarea[data-field="description"]:not(:disabled)::after {
} }
/* Form Elements */ /* 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 { .detail-group {
margin-bottom: 30px; margin-bottom: 30px;
padding: 15px; padding: 15px;
@@ -508,7 +555,7 @@ textarea[data-field="description"]:not(:disabled)::after {
border-radius: 0; border-radius: 0;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
transition: all 0.3s ease; transition: border-color 0.2s ease;
} }
input.editable { input.editable {
@@ -537,6 +584,8 @@ textarea.editable {
background: var(--bg-secondary); background: var(--bg-secondary);
cursor: default; cursor: default;
border-color: transparent; border-color: transparent;
overflow: hidden;
resize: none;
} }
/* Button Styles */ /* Button Styles */
@@ -548,7 +597,7 @@ textarea.editable {
font-weight: 500; font-weight: 500;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-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 { .btn.primary {
@@ -564,8 +613,6 @@ textarea.editable {
} }
.btn:hover { .btn:hover {
transform: translateY(-2px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
} }
/* Comments Section - TERMINAL STYLE */ /* Comments Section - TERMINAL STYLE */
@@ -629,7 +676,7 @@ textarea.editable {
margin-bottom: 1rem; margin-bottom: 1rem;
position: relative; position: relative;
box-shadow: none; box-shadow: none;
transition: all 0.3s ease; transition: border-color 0.2s ease;
animation: comment-appear 0.4s ease-out; animation: comment-appear 0.4s ease-out;
} }
@@ -646,8 +693,6 @@ textarea.editable {
.comment:hover { .comment:hover {
border-color: var(--terminal-amber); 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, .comment:hover::before,
@@ -764,13 +809,16 @@ textarea.editable {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; 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); font-family: var(--font-mono);
line-height: 1; line-height: 1;
} }
.comment-action-btn:hover { .comment-action-btn:hover,
.comment-action-btn:focus-visible {
background: rgba(0, 255, 65, 0.1); background: rgba(0, 255, 65, 0.1);
outline: 2px solid var(--terminal-amber);
outline-offset: 2px;
} }
.comment-action-btn.edit-btn:hover { .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 { .reply-form-container .reply-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1059,7 +1112,7 @@ textarea.editable {
font-size: 1em; font-size: 1em;
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--terminal-green); 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; position: relative;
margin-right: -2px; margin-right: -2px;
} }
@@ -1074,9 +1127,12 @@ textarea.editable {
color: var(--terminal-green); color: var(--terminal-green);
} }
.tab-btn:hover { .tab-btn:hover,
.tab-btn:focus-visible {
background: rgba(0, 255, 65, 0.05); background: rgba(0, 255, 65, 0.05);
color: var(--terminal-amber); color: var(--terminal-amber);
outline: 2px solid var(--terminal-amber);
outline-offset: -2px;
} }
.tab-btn.active { .tab-btn.active {
@@ -1159,7 +1215,7 @@ textarea.editable {
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
transition: .4s; transition: background-color 0.4s ease;
} }
.slider:before { .slider:before {
@@ -1169,8 +1225,8 @@ textarea.editable {
width: 16px; width: 16px;
left: 4px; left: 4px;
bottom: 4px; bottom: 4px;
background-color: white; background-color: var(--bg-primary);
transition: .4s; transition: transform 0.4s ease;
} }
.slider.round { .slider.round {
@@ -1328,24 +1384,24 @@ input:checked + .slider:before {
} }
body.dark-mode .timeline-content { body.dark-mode .timeline-content {
--card-bg: #2d3748; --card-bg: var(--bg-tertiary);
--border-color: #444; --border-color: var(--border-color);
--text-muted: #a0aec0; --text-muted: var(--text-muted);
--text-secondary: #cbd5e0; --text-secondary: var(--text-secondary);
background: #2d3748; background: var(--bg-tertiary);
color: #f7fafc; color: var(--text-primary);
} }
body.dark-mode .timeline-header strong { body.dark-mode .timeline-header strong {
color: #f7fafc; color: var(--text-primary);
} }
body.dark-mode .timeline-action { body.dark-mode .timeline-action {
color: #a0aec0; color: var(--text-muted);
} }
body.dark-mode .timeline-date { body.dark-mode .timeline-date {
color: #718096; color: var(--text-secondary);
} }
/* Status select dropdown */ /* Status select dropdown */
.status-select { .status-select {
@@ -1357,38 +1413,38 @@ body.dark-mode .timeline-date {
letter-spacing: 0.5px; letter-spacing: 0.5px;
border: 2px solid transparent; border: 2px solid transparent;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: opacity 0.15s ease, border-color 0.15s ease;
} }
.status-select:hover { .status-select:hover {
opacity: 0.9; opacity: 0.9;
border-color: rgba(255, 255, 255, 0.3); border-color: rgba(0, 255, 65, 0.4);
} }
.status-select:focus { .status-select:focus {
outline: none; outline: none;
border-color: rgba(255, 255, 255, 0.5); border-color: var(--terminal-amber);
} }
/* Status colors for dropdown */ /* Status colors for dropdown */
.status-select.status-open { .status-select.status-open {
background-color: var(--status-open) !important; background-color: var(--status-open) !important;
color: white !important; color: var(--bg-primary) !important;
} }
.status-select.status-in-progress { .status-select.status-in-progress {
background-color: var(--status-in-progress) !important; background-color: var(--status-in-progress) !important;
color: #212529 !important; color: var(--bg-primary) !important;
} }
.status-select.status-closed { .status-select.status-closed {
background-color: var(--status-closed) !important; background-color: var(--status-closed) !important;
color: white !important; color: var(--bg-primary) !important;
} }
.status-select.status-resolved { .status-select.status-resolved {
background-color: #28a745 !important; background-color: var(--status-open) !important;
color: white !important; color: var(--bg-primary) !important;
} }
/* Dropdown options inherit colors */ /* Dropdown options inherit colors */
@@ -1399,66 +1455,56 @@ body.dark-mode .timeline-date {
} }
body.dark-mode .status-select option { body.dark-mode .status-select option {
background-color: #2d3748; background-color: var(--bg-tertiary);
color: #f7fafc; color: var(--text-primary);
} }
/* Dark mode for Activity tab and general improvements */ /* Dark mode for Activity tab and general improvements */
body.dark-mode .tab-content { body.dark-mode .tab-content {
color: var(--text-primary, #f7fafc); color: var(--text-primary);
} }
body.dark-mode #activity-tab { body.dark-mode #activity-tab {
background: var(--bg-secondary, #1a202c); background: var(--bg-secondary);
color: var(--text-primary, #f7fafc); color: var(--text-primary);
} }
body.dark-mode #activity-tab p { body.dark-mode #activity-tab p {
color: var(--text-primary, #f7fafc); color: var(--text-primary);
} }
/* Comprehensive Dark Mode Fix - Ensure no white on white */ /* Comprehensive Dark Mode Fix - terminal CSS variables apply throughout */
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;
}
/* Ensure ticket container has dark background */ /* Ensure ticket container has dark background */
body.dark-mode .ticket-container { body.dark-mode .ticket-container {
background: #1a202c !important; background: var(--bg-secondary) !important;
color: #e2e8f0 !important; color: var(--text-primary) !important;
} }
/* Ensure all ticket details sections are dark */ /* Ensure all ticket details sections are dark */
body.dark-mode .ticket-details { body.dark-mode .ticket-details {
background: #1a202c !important; background: var(--bg-secondary) !important;
color: #e2e8f0 !important; color: var(--text-primary) !important;
} }
/* Ensure detail groups are dark */ /* Ensure detail groups are dark */
body.dark-mode .detail-group { body.dark-mode .detail-group {
background: transparent !important; background: transparent !important;
color: #e2e8f0 !important; color: var(--text-primary) !important;
} }
/* Ensure labels are visible */ /* Ensure labels are visible */
body.dark-mode .detail-group label, body.dark-mode .detail-group label,
body.dark-mode label { body.dark-mode label {
color: #cbd5e0 !important; color: var(--text-secondary) !important;
} }
/* Fix textarea and input fields */ /* Fix textarea and input fields */
body.dark-mode textarea, body.dark-mode textarea,
body.dark-mode input[type="text"] { body.dark-mode input[type="text"] {
background: #2d3748 !important; background: var(--bg-tertiary) !important;
color: #e2e8f0 !important; color: var(--text-primary) !important;
border-color: #4a5568 !important; border-color: var(--border-color) !important;
} }
/* Ensure timeline event backgrounds are dark */ /* Ensure timeline event backgrounds are dark */
@@ -1468,30 +1514,38 @@ body.dark-mode .timeline-event {
/* Fix any remaining white text issues */ /* Fix any remaining white text issues */
body.dark-mode .timeline-details { body.dark-mode .timeline-details {
color: #cbd5e0 !important; color: var(--text-secondary) !important;
background: transparent !important; background: transparent !important;
} }
/* Fix comment sections */ /* Fix comment sections */
body.dark-mode .comment { body.dark-mode .comment {
background: #2d3748 !important; background: var(--bg-tertiary) !important;
color: #e2e8f0 !important; color: var(--text-primary) !important;
} }
body.dark-mode .comment-text { body.dark-mode .comment-text {
color: #e2e8f0 !important; color: var(--text-primary) !important;
} }
body.dark-mode .comment-header { body.dark-mode .comment-header {
color: #cbd5e0 !important; color: var(--text-secondary) !important;
} }
/* Fix any form elements */ /* Fix any form elements */
body.dark-mode select, body.dark-mode select,
body.dark-mode .editable { body.dark-mode .editable {
background: #2d3748 !important; background: var(--bg-tertiary) !important;
color: #e2e8f0 !important; color: var(--text-primary) !important;
border-color: #4a5568 !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 ===== */ /* ===== RESPONSIVE DESIGN - TERMINAL EDITION ===== */
@@ -1608,7 +1662,7 @@ body.dark-mode .editable {
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: border-color 0.2s ease, background-color 0.2s ease;
background: var(--bg-primary); background: var(--bg-primary);
} }
@@ -1695,7 +1749,7 @@ body.dark-mode .editable {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border: 1px solid var(--terminal-green); border: 1px solid var(--terminal-green);
background: var(--bg-primary); background: var(--bg-primary);
transition: all 0.2s ease; transition: border-color 0.15s ease, background-color 0.15s ease;
} }
.attachment-item:hover { .attachment-item:hover {
@@ -1756,7 +1810,7 @@ body.dark-mode .editable {
.btn-danger:hover { .btn-danger:hover {
background: var(--priority-1); background: var(--priority-1);
color: white; color: var(--bg-primary);
} }
/* Mobile responsiveness for attachments */ /* Mobile responsiveness for attachments */
@@ -1786,12 +1840,11 @@ body.dark-mode .editable {
border-radius: 0; border-radius: 0;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: background-color 0.15s ease;
} }
.mention:hover { .mention:hover {
background: rgba(0, 255, 255, 0.2); background: rgba(0, 255, 255, 0.2);
text-shadow: 0 0 5px var(--terminal-cyan);
} }
.mention::before { .mention::before {
@@ -1821,7 +1874,7 @@ body.dark-mode .editable {
cursor: pointer; cursor: pointer;
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--terminal-green); color: var(--terminal-green);
transition: all 0.2s ease; transition: background-color 0.15s ease, color 0.15s ease;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -1862,7 +1915,7 @@ body.dark-mode .editable {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; 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; min-width: 32px;
} }
@@ -1915,7 +1968,7 @@ body.dark-mode .editable {
color: var(--terminal-green); color: var(--terminal-green);
text-decoration: none; text-decoration: none;
font-family: var(--font-mono); 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 { .export-dropdown-content a:hover {
@@ -2137,6 +2190,38 @@ body.dark-mode .editable {
font-family: var(--font-mono); 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 */
.upload-progress { .upload-progress {
margin-top: 1rem; margin-top: 1rem;
@@ -2645,7 +2730,7 @@ body.dark-mode .editable {
min-height: 44px; min-height: 44px;
padding: 0.5rem; padding: 0.5rem;
background: rgba(0, 255, 65, 0.05); background: rgba(0, 255, 65, 0.05);
border-radius: 4px; border-radius: 0;
} }
} }

View File

@@ -7,8 +7,7 @@
function openAdvancedSearch() { function openAdvancedSearch() {
const modal = document.getElementById('advancedSearchModal'); const modal = document.getElementById('advancedSearchModal');
if (modal) { if (modal) {
modal.style.display = 'flex'; lt.modal.open('advancedSearchModal');
document.body.classList.add('modal-open');
loadUsersForSearch(); loadUsersForSearch();
populateCurrentFilters(); populateCurrentFilters();
loadSavedFilters(); loadSavedFilters();
@@ -17,28 +16,13 @@ function openAdvancedSearch() {
// Close advanced search modal // Close advanced search modal
function closeAdvancedSearch() { function closeAdvancedSearch() {
const modal = document.getElementById('advancedSearchModal'); lt.modal.close('advancedSearchModal');
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
}
// Close modal when clicking on backdrop
function closeOnAdvancedSearchBackdropClick(event) {
const modal = document.getElementById('advancedSearchModal');
if (event.target === modal) {
closeAdvancedSearch();
}
} }
// Load users for dropdown // Load users for dropdown
async function loadUsersForSearch() { async function loadUsersForSearch() {
try { try {
const response = await fetch('/api/get_users.php', { const data = await lt.api.get('/api/get_users.php');
credentials: 'same-origin'
});
const data = await response.json();
if (data.success && data.users) { if (data.success && data.users) {
const createdBySelect = document.getElementById('adv-created-by'); const createdBySelect = document.getElementById('adv-created-by');
@@ -68,7 +52,7 @@ async function loadUsersForSearch() {
}); });
} }
} catch (error) { } catch (error) {
console.error('Error loading users:', error); lt.toast.error('Error loading users');
} }
} }
@@ -156,37 +140,21 @@ async function saveCurrentFilter() {
'My Filter', 'My Filter',
async (filterName) => { async (filterName) => {
if (!filterName || filterName.trim() === '') { if (!filterName || filterName.trim() === '') {
toast.warning('Filter name cannot be empty', 2000); lt.toast.warning('Filter name cannot be empty', 2000);
return; return;
} }
const filterCriteria = getCurrentFilterCriteria(); const filterCriteria = getCurrentFilterCriteria();
try { try {
const response = await fetch('/api/saved_filters.php', { await lt.api.post('/api/saved_filters.php', {
method: 'POST', filter_name: filterName.trim(),
credentials: 'same-origin', filter_criteria: filterCriteria
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
filter_name: filterName.trim(),
filter_criteria: filterCriteria
})
}); });
lt.toast.success(`Filter "${filterName}" saved successfully!`, 3000);
const result = await response.json(); loadSavedFilters();
if (result.success) {
toast.success(`Filter "${filterName}" saved successfully!`, 3000);
loadSavedFilters();
} else {
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'), 4000);
}
} catch (error) { } catch (error) {
console.error('Error saving filter:', error); lt.toast.error('Error saving filter: ' + (error.message || 'Unknown error'), 4000);
toast.error('Error saving filter', 4000);
} }
} }
); );
@@ -233,16 +201,12 @@ function getCurrentFilterCriteria() {
// Load saved filters // Load saved filters
async function loadSavedFilters() { async function loadSavedFilters() {
try { try {
const response = await fetch('/api/saved_filters.php', { const data = await lt.api.get('/api/saved_filters.php');
credentials: 'same-origin'
});
const data = await response.json();
if (data.success && data.filters) { if (data.success && data.filters) {
populateSavedFiltersDropdown(data.filters); populateSavedFiltersDropdown(data.filters);
} }
} catch (error) { } catch (error) {
console.error('Error loading saved filters:', error); lt.toast.error('Error loading saved filters');
} }
} }
@@ -277,7 +241,7 @@ function loadSavedFilter() {
const criteria = JSON.parse(selectedOption.dataset.criteria); const criteria = JSON.parse(selectedOption.dataset.criteria);
applySavedFilterCriteria(criteria); applySavedFilterCriteria(criteria);
} catch (error) { } catch (error) {
console.error('Error loading filter:', error); lt.toast.error('Error loading filter');
} }
} }
@@ -314,9 +278,7 @@ async function deleteSavedFilter() {
const selectedOption = dropdown.options[dropdown.selectedIndex]; const selectedOption = dropdown.options[dropdown.selectedIndex];
if (!selectedOption || selectedOption.value === '') { if (!selectedOption || selectedOption.value === '') {
if (typeof toast !== 'undefined') { lt.toast.error('Please select a filter to delete');
toast.error('Please select a filter to delete');
}
return; return;
} }
@@ -329,45 +291,21 @@ async function deleteSavedFilter() {
'error', 'error',
async () => { async () => {
try { try {
const response = await fetch('/api/saved_filters.php', { await lt.api.delete('/api/saved_filters.php', { filter_id: filterId });
method: 'DELETE', lt.toast.success('Filter deleted successfully', 3000);
credentials: 'same-origin', loadSavedFilters();
headers: { resetAdvancedSearch();
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ filter_id: filterId })
});
const result = await response.json();
if (result.success) {
toast.success('Filter deleted successfully', 3000);
loadSavedFilters();
resetAdvancedSearch();
} else {
toast.error('Failed to delete filter', 4000);
}
} catch (error) { } catch (error) {
console.error('Error deleting filter:', error); lt.toast.error('Error deleting filter', 4000);
toast.error('Error deleting filter', 4000);
} }
} }
); );
} }
// Keyboard shortcut (Ctrl+Shift+F) // Keyboard shortcut (Ctrl+Shift+F) — ESC is handled globally by lt.keys.initDefaults()
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'F') { if (e.ctrlKey && e.shiftKey && e.key === 'F') {
e.preventDefault(); e.preventDefault();
openAdvancedSearch(); openAdvancedSearch();
} }
// ESC to close
if (e.key === 'Escape') {
const modal = document.getElementById('advancedSearchModal');
if (modal && modal.style.display === 'flex') {
closeAdvancedSearch();
}
}
}); });

View File

@@ -60,26 +60,13 @@ function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = tru
const container = document.querySelector(containerSelector); const container = document.querySelector(containerSelector);
if (!container || !banner) { if (!container || !banner) {
console.error('ASCII Banner: Container or banner not found', { bannerId, containerSelector });
return; return;
} }
// Create pre element for ASCII art // Create pre element for ASCII art
const pre = document.createElement('pre'); const pre = document.createElement('pre');
pre.className = 'ascii-banner'; pre.className = addGlow ? 'ascii-banner ascii-banner--glow' : 'ascii-banner';
pre.style.margin = '0';
pre.style.fontFamily = 'var(--font-mono)';
pre.style.color = 'var(--terminal-green)';
if (addGlow) {
pre.style.textShadow = 'var(--glow-green)';
}
pre.style.fontSize = getBannerFontSize(bannerId); pre.style.fontSize = getBannerFontSize(bannerId);
pre.style.lineHeight = '1.2';
pre.style.whiteSpace = 'pre';
pre.style.overflow = 'visible';
pre.style.textAlign = 'center';
container.appendChild(pre); container.appendChild(pre);
@@ -179,8 +166,7 @@ function animatedWelcome(containerSelector) {
banner.addEventListener('bannerComplete', () => { banner.addEventListener('bannerComplete', () => {
const cursor = document.createElement('span'); const cursor = document.createElement('span');
cursor.textContent = '█'; cursor.textContent = '█';
cursor.style.animation = 'blink-caret 0.75s step-end infinite'; cursor.className = 'ascii-banner-cursor';
cursor.style.marginLeft = '5px';
banner.appendChild(cursor); banner.appendChild(cursor);
}); });
} }

793
assets/js/base.js Normal file
View File

@@ -0,0 +1,793 @@
/**
* LOTUSGUILD TERMINAL DESIGN SYSTEM v1.0 — base.js
* Core JavaScript utilities shared across all LotusGuild applications
*
* Apps: Tinker Tickets (PHP), PULSE (Node.js), GANDALF (Flask)
* Namespace: window.lt
*
* CONTENTS
* 1. HTML Escape
* 2. Toast Notifications
* 3. Terminal Audio (beep)
* 4. Modal Management
* 5. Tab Management
* 6. Boot Sequence Animation
* 7. Keyboard Shortcuts
* 8. Sidebar Collapse
* 9. CSRF Token Helpers
* 10. Fetch Helpers (JSON API wrapper)
* 11. Time Formatting
* 12. Bytes Formatting
* 13. Table Keyboard Navigation
* 14. Sortable Table Headers
* 15. Stats Widget Filtering
* 16. Auto-refresh Manager
* 17. Initialisation
*/
(function (global) {
'use strict';
/* ----------------------------------------------------------------
1. HTML ESCAPE
---------------------------------------------------------------- */
/**
* Escape a value for safe insertion into innerHTML.
* Always prefer textContent/innerText when possible, but use this
* when you must build HTML strings (e.g. template literals for lists).
*
* @param {*} str
* @returns {string}
*/
function escHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/* ----------------------------------------------------------------
2. TOAST NOTIFICATIONS
----------------------------------------------------------------
Usage:
lt.toast.success('Ticket saved');
lt.toast.error('Network error', 5000);
lt.toast.warning('Rate limit approaching');
lt.toast.info('Workflow started');
---------------------------------------------------------------- */
const _toastQueue = [];
let _toastActive = false;
/**
* @param {string} message
* @param {'success'|'error'|'warning'|'info'} type
* @param {number} [duration=3500] ms before auto-dismiss
*/
function showToast(message, type, duration) {
type = type || 'info';
duration = duration || 3500;
if (_toastActive) {
_toastQueue.push({ message, type, duration });
return;
}
_displayToast(message, type, duration);
}
function _displayToast(message, type, duration) {
_toastActive = true;
let container = document.querySelector('.lt-toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'lt-toast-container';
document.body.appendChild(container);
}
const icons = { success: '✓', error: '✗', warning: '!', info: 'i' };
const toast = document.createElement('div');
toast.className = 'lt-toast lt-toast-' + type;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'polite');
const iconEl = document.createElement('span');
iconEl.className = 'lt-toast-icon';
iconEl.textContent = '[' + (icons[type] || 'i') + ']';
const msgEl = document.createElement('span');
msgEl.className = 'lt-toast-msg';
msgEl.textContent = message;
const closeEl = document.createElement('button');
closeEl.className = 'lt-toast-close';
closeEl.textContent = '✕';
closeEl.setAttribute('aria-label', 'Dismiss');
closeEl.addEventListener('click', () => _dismissToast(toast));
toast.appendChild(iconEl);
toast.appendChild(msgEl);
toast.appendChild(closeEl);
container.appendChild(toast);
/* Auto-dismiss */
const timer = setTimeout(() => _dismissToast(toast), duration);
toast._lt_timer = timer;
/* Optional audio feedback */
_beep(type);
}
function _dismissToast(toast) {
if (!toast || !toast.parentNode) return;
clearTimeout(toast._lt_timer);
toast.classList.add('lt-toast--hiding');
setTimeout(() => {
if (toast.parentNode) toast.parentNode.removeChild(toast);
_toastActive = false;
if (_toastQueue.length) {
const next = _toastQueue.shift();
_displayToast(next.message, next.type, next.duration);
}
}, 320);
}
const toast = {
success: (msg, dur) => showToast(msg, 'success', dur),
error: (msg, dur) => showToast(msg, 'error', dur),
warning: (msg, dur) => showToast(msg, 'warning', dur),
info: (msg, dur) => showToast(msg, 'info', dur),
};
/* ----------------------------------------------------------------
3. TERMINAL AUDIO
----------------------------------------------------------------
Usage: lt.beep('success' | 'error' | 'info')
Silent-fails if Web Audio API is unavailable.
---------------------------------------------------------------- */
function _beep(type) {
try {
const ctx = new (global.AudioContext || global.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = type === 'success' ? 880
: type === 'error' ? 220
: 440;
osc.type = 'sine';
gain.gain.setValueAtTime(0.08, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.12);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.12);
} catch (_) { /* silently fail */ }
}
/* ----------------------------------------------------------------
4. MODAL MANAGEMENT
----------------------------------------------------------------
Usage:
lt.modal.open('my-modal-id');
lt.modal.close('my-modal-id');
lt.modal.closeAll();
HTML contract:
<div id="my-modal-id" class="lt-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="myModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="myModalTitle">Title</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">…</div>
<div class="lt-modal-footer">…</div>
</div>
</div>
---------------------------------------------------------------- */
function openModal(id) {
const el = typeof id === 'string' ? document.getElementById(id) : id;
if (!el) return;
el.classList.add('show');
el.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
/* Focus first focusable element */
const first = el.querySelector('button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (first) setTimeout(() => first.focus(), 50);
}
function closeModal(id) {
const el = typeof id === 'string' ? document.getElementById(id) : id;
if (!el) return;
el.classList.remove('show');
el.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
}
function closeAllModals() {
document.querySelectorAll('.lt-modal-overlay.show').forEach(closeModal);
}
/* Delegated close handlers */
document.addEventListener('click', function (e) {
/* Click on overlay backdrop (outside .lt-modal) */
if (e.target.classList.contains('lt-modal-overlay')) {
closeModal(e.target);
return;
}
/* [data-modal-close] button */
const closeBtn = e.target.closest('[data-modal-close]');
if (closeBtn) {
const overlay = closeBtn.closest('.lt-modal-overlay');
if (overlay) closeModal(overlay);
}
/* [data-modal-open="id"] trigger */
const openBtn = e.target.closest('[data-modal-open]');
if (openBtn) openModal(openBtn.dataset.modalOpen);
});
const modal = { open: openModal, close: closeModal, closeAll: closeAllModals };
/* ----------------------------------------------------------------
5. TAB MANAGEMENT
----------------------------------------------------------------
Usage:
lt.tabs.init(); // auto-wires all .lt-tab elements
lt.tabs.switch('tab-panel-id');
HTML contract:
<div class="lt-tabs">
<button class="lt-tab active" data-tab="panel-one">One</button>
<button class="lt-tab" data-tab="panel-two">Two</button>
</div>
<div id="panel-one" class="lt-tab-panel active">…</div>
<div id="panel-two" class="lt-tab-panel">…</div>
Persistence: localStorage key 'lt_activeTab_<page>'
---------------------------------------------------------------- */
function switchTab(panelId) {
document.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active'));
const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]');
const panel = document.getElementById(panelId);
if (btn) btn.classList.add('active');
if (panel) panel.classList.add('active');
try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
}
function initTabs() {
/* Restore from localStorage */
try {
const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
if (saved && document.getElementById(saved)) { switchTab(saved); return; }
} catch (_) {}
/* Wire click handlers */
document.querySelectorAll('.lt-tab[data-tab]').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
}
const tabs = { init: initTabs, switch: switchTab };
/* ----------------------------------------------------------------
6. BOOT SEQUENCE ANIMATION
----------------------------------------------------------------
Usage:
lt.boot.run('APP NAME'); // shows once per session
lt.boot.run('APP NAME', true); // force show even if already seen
HTML contract (add to <body>, hidden by default):
<div id="lt-boot" class="lt-boot-overlay" style="display:none">
<pre id="lt-boot-text" class="lt-boot-text"></pre>
</div>
---------------------------------------------------------------- */
function runBoot(appName, force) {
const storageKey = 'lt_booted_' + (appName || 'app');
if (!force && sessionStorage.getItem(storageKey)) return;
const overlay = document.getElementById('lt-boot');
const pre = document.getElementById('lt-boot-text');
if (!overlay || !pre) return;
overlay.style.display = 'flex';
const name = (appName || 'TERMINAL').toUpperCase();
const titleStr = name + ' v1.0';
const innerWidth = 43;
const leftPad = Math.max(0, Math.floor((innerWidth - titleStr.length) / 2));
const rightPad = Math.max(0, innerWidth - titleStr.length - leftPad);
const messages = [
'╔═══════════════════════════════════════════╗',
'║' + ' '.repeat(leftPad) + titleStr + ' '.repeat(rightPad) + '║',
'║ BOOTING SYSTEM... ║',
'╚═══════════════════════════════════════════╝',
'',
'[ OK ] Checking kernel modules...',
'[ OK ] Mounting filesystem...',
'[ OK ] Initializing database connection...',
'[ OK ] Loading user session...',
'[ OK ] Applying security headers...',
'[ OK ] Rendering terminal interface...',
'',
'> SYSTEM READY ✓',
'',
];
let i = 0;
pre.textContent = '';
const interval = setInterval(() => {
if (i < messages.length) {
pre.textContent += messages[i] + '\n';
i++;
} else {
clearInterval(interval);
setTimeout(() => {
overlay.classList.add('fade-out');
setTimeout(() => {
overlay.style.display = 'none';
overlay.classList.remove('fade-out');
}, 520);
}, 400);
sessionStorage.setItem(storageKey, '1');
}
}, 80);
}
const boot = { run: runBoot };
/* ----------------------------------------------------------------
7. KEYBOARD SHORTCUTS
----------------------------------------------------------------
Register handlers:
lt.keys.on('ctrl+k', () => searchBox.focus());
lt.keys.on('?', showHelpModal);
lt.keys.on('Escape', lt.modal.closeAll);
Built-in defaults (activate with lt.keys.initDefaults()):
ESC → close all modals
? → show #lt-keys-help modal if present
Ctrl/⌘+K → focus .lt-search-input
---------------------------------------------------------------- */
const _keyHandlers = {};
function normalizeKey(combo) {
return combo
.replace(/ctrl\+/i, 'ctrl+')
.replace(/cmd\+/i, 'ctrl+') /* treat Cmd as Ctrl */
.replace(/meta\+/i, 'ctrl+')
.toLowerCase();
}
function registerKey(combo, handler) {
_keyHandlers[normalizeKey(combo)] = handler;
}
function unregisterKey(combo) {
delete _keyHandlers[normalizeKey(combo)];
}
document.addEventListener('keydown', function (e) {
const inInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)
|| e.target.isContentEditable;
/* Build the combo string */
let combo = '';
if (e.ctrlKey || e.metaKey) combo += 'ctrl+';
if (e.altKey) combo += 'alt+';
if (e.shiftKey) combo += 'shift+';
combo += e.key.toLowerCase();
/* Always fire ESC, Ctrl combos regardless of input focus */
const alwaysFire = e.key === 'Escape' || e.ctrlKey || e.metaKey;
if (inInput && !alwaysFire) return;
const handler = _keyHandlers[combo];
if (handler) {
e.preventDefault();
handler(e);
}
});
function initDefaultKeys() {
registerKey('Escape', closeAllModals);
registerKey('?', () => {
const helpModal = document.getElementById('lt-keys-help');
if (helpModal) openModal(helpModal);
});
registerKey('ctrl+k', () => {
const search = document.querySelector('.lt-search-input');
if (search) { search.focus(); search.select(); }
});
}
const keys = {
on: registerKey,
off: unregisterKey,
initDefaults: initDefaultKeys,
};
/* ----------------------------------------------------------------
8. SIDEBAR COLLAPSE
----------------------------------------------------------------
Usage: lt.sidebar.init();
HTML contract:
<aside class="lt-sidebar" id="lt-sidebar">
<div class="lt-sidebar-header">
Filters
<button class="lt-sidebar-toggle" data-sidebar-toggle="lt-sidebar">◀</button>
</div>
<div class="lt-sidebar-body">…</div>
</aside>
---------------------------------------------------------------- */
function initSidebar() {
document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => {
const sidebar = document.getElementById(btn.dataset.sidebarToggle);
if (!sidebar) return;
/* Restore state */
const collapsed = sessionStorage.getItem('lt_sidebar_' + btn.dataset.sidebarToggle) === '1';
if (collapsed) {
sidebar.classList.add('collapsed');
btn.textContent = '▶';
}
btn.addEventListener('click', () => {
sidebar.classList.toggle('collapsed');
const isCollapsed = sidebar.classList.contains('collapsed');
btn.textContent = isCollapsed ? '▶' : '◀';
try { sessionStorage.setItem('lt_sidebar_' + btn.dataset.sidebarToggle, isCollapsed ? '1' : '0'); } catch (_) {}
});
});
}
const sidebar = { init: initSidebar };
/* ----------------------------------------------------------------
9. CSRF TOKEN HELPERS
----------------------------------------------------------------
PHP apps: window.CSRF_TOKEN is set by the view via:
<script nonce="...">window.CSRF_TOKEN = '<?= CsrfMiddleware::getToken() ?>';</script>
Node apps: set via: window.CSRF_TOKEN = '<%= csrfToken %>';
Flask: use Flask-WTF meta tag or inject via template.
Usage:
const headers = lt.csrf.headers();
fetch('/api/foo', { method: 'POST', headers: lt.csrf.headers(), body: … });
---------------------------------------------------------------- */
function csrfHeaders() {
const token = global.CSRF_TOKEN || '';
return token ? { 'X-CSRF-Token': token } : {};
}
const csrf = { headers: csrfHeaders };
/* ----------------------------------------------------------------
10. FETCH HELPERS
----------------------------------------------------------------
Usage:
const data = await lt.api.get('/api/tickets');
const res = await lt.api.post('/api/update_ticket.php', { id: 1, status: 'Closed' });
const res = await lt.api.delete('/api/ticket_dependencies.php', { id: 5 });
All methods:
- Automatically set Content-Type: application/json
- Attach CSRF token header
- Parse JSON response
- On non-2xx: throw an Error with the server's error message
---------------------------------------------------------------- */
async function apiFetch(method, url, body) {
const opts = {
method,
headers: Object.assign(
{ 'Content-Type': 'application/json' },
csrfHeaders()
),
};
if (body !== undefined) opts.body = JSON.stringify(body);
let resp;
try {
resp = await fetch(url, opts);
} catch (networkErr) {
throw new Error('Network error: ' + networkErr.message);
}
let data;
try {
data = await resp.json();
} catch (_) {
data = { success: resp.ok };
}
if (!resp.ok) {
throw new Error(data.error || data.message || 'HTTP ' + resp.status);
}
return data;
}
const api = {
get: (url) => apiFetch('GET', url),
post: (url, body) => apiFetch('POST', url, body),
put: (url, body) => apiFetch('PUT', url, body),
patch: (url, body) => apiFetch('PATCH', url, body),
delete: (url, body) => apiFetch('DELETE', url, body),
};
/* ----------------------------------------------------------------
11. TIME FORMATTING
---------------------------------------------------------------- */
/**
* Returns a human-readable relative time string.
* @param {string|number|Date} value ISO string, Unix ms, or Date
* @returns {string} e.g. "5m ago", "2h ago", "3d ago"
*/
function timeAgo(value) {
const date = value instanceof Date ? value : new Date(value);
if (isNaN(date)) return '—';
const diff = Math.floor((Date.now() - date.getTime()) / 1000);
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
/**
* Format seconds → "1h 23m 45s" style.
* @param {number} secs
* @returns {string}
*/
function formatUptime(secs) {
secs = Math.floor(secs);
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
const parts = [];
if (d) parts.push(d + 'd');
if (h) parts.push(h + 'h');
if (m) parts.push(m + 'm');
if (!d) parts.push(s + 's');
return parts.join(' ') || '0s';
}
/**
* Format an ISO datetime string for display.
* Uses the timezone configured in window.APP_TIMEZONE (PHP apps)
* or falls back to the browser locale.
*/
function formatDate(value) {
const date = value instanceof Date ? value : new Date(value);
if (isNaN(date)) return '—';
const tz = global.APP_TIMEZONE || undefined;
try {
return date.toLocaleString(undefined, {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
} catch (_) {
return date.toLocaleString();
}
}
const time = { ago: timeAgo, uptime: formatUptime, format: formatDate };
/* ----------------------------------------------------------------
12. BYTES FORMATTING
---------------------------------------------------------------- */
/**
* @param {number} bytes
* @returns {string} e.g. "1.23 GB"
*/
function formatBytes(bytes) {
if (bytes === null || bytes === undefined) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
return bytes.toFixed(i === 0 ? 0 : 2) + ' ' + units[i];
}
/* ----------------------------------------------------------------
13. TABLE KEYBOARD NAVIGATION (vim-style j/k)
----------------------------------------------------------------
Usage: lt.tableNav.init('my-table-id');
Keys registered:
j or ArrowDown → move selection down
k or ArrowUp → move selection up
Enter → follow first <a> in selected row
---------------------------------------------------------------- */
function initTableNav(tableId) {
const table = tableId ? document.getElementById(tableId) : document.querySelector('.lt-table');
if (!table) return;
function rows() { return Array.from(table.querySelectorAll('tbody tr')); }
function selected() { return table.querySelector('tbody tr.lt-row-selected'); }
function move(dir) {
const all = rows();
if (!all.length) return;
const cur = selected();
const idx = cur ? all.indexOf(cur) : -1;
const next = dir === 'down'
? all[idx < all.length - 1 ? idx + 1 : 0]
: all[idx > 0 ? idx - 1 : all.length - 1];
if (cur) cur.classList.remove('lt-row-selected');
next.classList.add('lt-row-selected');
next.scrollIntoView({ block: 'nearest' });
}
keys.on('j', () => move('down'));
keys.on('ArrowDown', () => move('down'));
keys.on('k', () => move('up'));
keys.on('ArrowUp', () => move('up'));
keys.on('Enter', () => {
const row = selected();
if (!row) return;
const link = row.querySelector('a[href]');
if (link) global.location.href = link.href;
});
}
const tableNav = { init: initTableNav };
/* ----------------------------------------------------------------
14. SORTABLE TABLE HEADERS
----------------------------------------------------------------
Usage: lt.sortTable.init('my-table-id');
Markup: add data-sort-key="field" to <th> elements.
Sorts rows client-side by the text content of the matching column.
---------------------------------------------------------------- */
function initSortTable(tableId) {
const table = document.getElementById(tableId);
if (!table) return;
const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
ths.forEach((th, colIdx) => {
let dir = 'asc';
th.addEventListener('click', () => {
/* Reset all headers */
ths.forEach(h => h.removeAttribute('data-sort'));
th.setAttribute('data-sort', dir);
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const aText = (a.cells[colIdx] || {}).textContent || '';
const bText = (b.cells[colIdx] || {}).textContent || '';
const n = !isNaN(parseFloat(aText)) && !isNaN(parseFloat(bText));
const cmp = n
? parseFloat(aText) - parseFloat(bText)
: aText.localeCompare(bText);
return dir === 'asc' ? cmp : -cmp;
});
rows.forEach(r => tbody.appendChild(r));
dir = dir === 'asc' ? 'desc' : 'asc';
});
});
}
const sortTable = { init: initSortTable };
/* ----------------------------------------------------------------
15. STATS WIDGET FILTERING
----------------------------------------------------------------
Usage: lt.statsFilter.init();
HTML contract:
<div class="lt-stat-card" data-filter-key="status" data-filter-val="Open">…</div>
<!-- clicking the card adds ?filter=status:Open to the URL and
calls the optional window.lt_onStatFilter(key, val) hook -->
---------------------------------------------------------------- */
function initStatsFilter() {
document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => {
card.addEventListener('click', () => {
const key = card.dataset.filterKey;
const val = card.dataset.filterVal;
/* Toggle active state */
const wasActive = card.classList.contains('active');
document.querySelectorAll('.lt-stat-card').forEach(c => c.classList.remove('active'));
if (!wasActive) card.classList.add('active');
/* Call app-specific filter hook if defined */
if (typeof global.lt_onStatFilter === 'function') {
global.lt_onStatFilter(wasActive ? null : key, wasActive ? null : val);
}
});
});
}
const statsFilter = { init: initStatsFilter };
/* ----------------------------------------------------------------
16. AUTO-REFRESH MANAGER
----------------------------------------------------------------
Usage:
lt.autoRefresh.start(refreshFn, 30000); // every 30 s
lt.autoRefresh.stop();
lt.autoRefresh.now(); // trigger immediately + restart timer
---------------------------------------------------------------- */
let _arTimer = null;
let _arFn = null;
let _arInterval = 30000;
function arStart(fn, intervalMs) {
arStop();
_arFn = fn;
_arInterval = intervalMs || 30000;
_arTimer = setInterval(_arFn, _arInterval);
}
function arStop() {
if (_arTimer) { clearInterval(_arTimer); _arTimer = null; }
}
function arNow() {
arStop();
if (_arFn) {
_arFn();
_arTimer = setInterval(_arFn, _arInterval);
}
}
const autoRefresh = { start: arStart, stop: arStop, now: arNow };
/* ----------------------------------------------------------------
17. INITIALISATION
----------------------------------------------------------------
Called automatically on DOMContentLoaded.
Each sub-system can also be initialised manually after the DOM
has been updated with AJAX content.
---------------------------------------------------------------- */
function init() {
initTabs();
initSidebar();
initDefaultKeys();
initStatsFilter();
/* Boot sequence: runs if #lt-boot element is present */
const bootEl = document.getElementById('lt-boot');
if (bootEl) {
const appName = bootEl.dataset.appName || document.title;
runBoot(appName);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
/* ----------------------------------------------------------------
Public API
---------------------------------------------------------------- */
global.lt = {
escHtml,
toast,
beep: _beep,
modal,
tabs,
boot,
keys,
sidebar,
csrf,
api,
time,
bytes: { format: formatBytes },
tableNav,
sortTable,
statsFilter,
autoRefresh,
};
}(window));

File diff suppressed because it is too large Load Diff

View File

@@ -1,173 +1,9 @@
/** /**
* Keyboard shortcuts for power users * Keyboard shortcuts for power users.
* App-specific shortcuts registered via lt.keys.on() from web_template/base.js.
* ESC, Ctrl+K, and ? are handled by lt.keys.initDefaults().
*/ */
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('keydown', function(e) {
// ESC: Close modals, cancel edit mode, blur inputs
if (e.key === 'Escape') {
// Close any open modals first
const openModals = document.querySelectorAll('.modal-overlay');
let closedModal = false;
openModals.forEach(modal => {
if (modal.style.display !== 'none' && modal.offsetParent !== null) {
modal.remove();
document.body.classList.remove('modal-open');
closedModal = true;
}
});
// Close settings modal if open
const settingsModal = document.getElementById('settingsModal');
if (settingsModal && settingsModal.style.display !== 'none') {
settingsModal.style.display = 'none';
document.body.classList.remove('modal-open');
closedModal = true;
}
// Close advanced search modal if open
const searchModal = document.getElementById('advancedSearchModal');
if (searchModal && searchModal.style.display !== 'none') {
searchModal.style.display = 'none';
document.body.classList.remove('modal-open');
closedModal = true;
}
// If we closed a modal, stop here
if (closedModal) {
e.preventDefault();
return;
}
// Blur any focused input
if (e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable) {
e.target.blur();
}
// Cancel edit mode on ticket pages
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
window.location.reload();
}
return;
}
// Skip other shortcuts if user is typing in an input/textarea
if (e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable) {
return;
}
// Ctrl/Cmd + E: Toggle edit mode (on ticket pages)
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
e.preventDefault();
const editButton = document.getElementById('editButton');
if (editButton) {
editButton.click();
toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
}
}
// Ctrl/Cmd + S: Save ticket (on ticket pages)
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
editButton.click();
toast.success('Saving ticket...');
}
}
// Ctrl/Cmd + K: Focus search (on dashboard)
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
const searchBox = document.querySelector('.search-box');
if (searchBox) {
searchBox.focus();
searchBox.select();
}
}
// ? : Show keyboard shortcuts help (requires Shift on most keyboards)
if (e.key === '?') {
e.preventDefault();
showKeyboardHelp();
}
// J: Move to next row in table (Gmail-style)
if (e.key === 'j') {
e.preventDefault();
navigateTableRow('next');
}
// K: Move to previous row in table (Gmail-style)
if (e.key === 'k') {
e.preventDefault();
navigateTableRow('prev');
}
// Enter: Open selected ticket
if (e.key === 'Enter') {
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
if (selectedRow) {
e.preventDefault();
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
if (ticketLink) {
window.location.href = ticketLink.href;
}
}
}
// N: Create new ticket (on dashboard)
if (e.key === 'n') {
e.preventDefault();
const newTicketBtn = document.querySelector('a[href*="/create"]');
if (newTicketBtn) {
window.location.href = newTicketBtn.href;
}
}
// C: Focus comment textarea (on ticket page)
if (e.key === 'c') {
const commentBox = document.getElementById('newComment');
if (commentBox) {
e.preventDefault();
commentBox.focus();
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// G then D: Go to Dashboard (vim-style)
if (e.key === 'g') {
window._pendingG = true;
setTimeout(() => { window._pendingG = false; }, 1000);
}
if (e.key === 'd' && window._pendingG) {
e.preventDefault();
window._pendingG = false;
window.location.href = '/';
}
// 1-4: Quick status change on ticket page
if (['1', '2', '3', '4'].includes(e.key)) {
const statusSelect = document.getElementById('statusSelect');
if (statusSelect && !document.querySelector('.modal-overlay')) {
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
const targetStatus = statusMap[e.key];
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
if (option && !option.disabled) {
e.preventDefault();
statusSelect.value = targetStatus;
statusSelect.dispatchEvent(new Event('change'));
}
}
}
});
});
// Track currently selected row for J/K navigation // Track currently selected row for J/K navigation
let currentSelectedRowIndex = -1; let currentSelectedRowIndex = -1;
@@ -175,7 +11,6 @@ function navigateTableRow(direction) {
const rows = document.querySelectorAll('tbody tr'); const rows = document.querySelectorAll('tbody tr');
if (rows.length === 0) return; if (rows.length === 0) return;
// Remove current selection
rows.forEach(row => row.classList.remove('keyboard-selected')); rows.forEach(row => row.classList.remove('keyboard-selected'));
if (direction === 'next') { if (direction === 'next') {
@@ -184,7 +19,6 @@ function navigateTableRow(direction) {
currentSelectedRowIndex = Math.max(currentSelectedRowIndex - 1, 0); currentSelectedRowIndex = Math.max(currentSelectedRowIndex - 1, 0);
} }
// Add selection to new row
const selectedRow = rows[currentSelectedRowIndex]; const selectedRow = rows[currentSelectedRowIndex];
if (selectedRow) { if (selectedRow) {
selectedRow.classList.add('keyboard-selected'); selectedRow.classList.add('keyboard-selected');
@@ -193,59 +27,140 @@ function navigateTableRow(direction) {
} }
function showKeyboardHelp() { function showKeyboardHelp() {
// Check if help is already showing if (document.getElementById('keyboardHelpModal')) return;
if (document.getElementById('keyboardHelpModal')) {
return;
}
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.id = 'keyboardHelpModal'; modal.id = 'keyboardHelpModal';
modal.className = 'modal-overlay'; modal.className = 'lt-modal-overlay';
modal.setAttribute('aria-hidden', 'true');
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', 'keyboardHelpModalTitle');
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content ascii-frame-outer" style="max-width: 500px;"> <div class="lt-modal lt-modal-sm">
<div class="ascii-frame"> <div class="lt-modal-header">
<div class="ascii-content"> <span class="lt-modal-title" id="keyboardHelpModalTitle">KEYBOARD SHORTCUTS</span>
<h3 style="margin: 0 0 1rem 0; color: var(--terminal-green);">KEYBOARD SHORTCUTS</h3> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
<div class="modal-body" style="padding: 0;"> </div>
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Navigation</h4> <div class="lt-modal-body">
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;"> <h4 class="kb-section-heading">Navigation</h4>
<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> <table class="kb-shortcuts-table">
<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><kbd>J</kbd></td><td>Next 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><kbd>K</kbd></td><td>Previous ticket in list</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> <tr><td><kbd>Enter</kbd></td><td>Open selected ticket</td></tr>
</table> <tr><td><kbd>G</kbd> then <kbd>D</kbd></td><td>Go to Dashboard</td></tr>
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Actions</h4> </table>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;"> <h4 class="kb-section-heading">Actions</h4>
<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> <table class="kb-shortcuts-table">
<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><kbd>N</kbd></td><td>New ticket</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><kbd>C</kbd></td><td>Focus comment box</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>Ctrl/Cmd + S</kbd></td><td style="padding: 0.4rem;">Save Changes</td></tr> <tr><td><kbd>Ctrl/Cmd+E</kbd></td><td>Toggle Edit Mode</td></tr>
</table> <tr><td><kbd>Ctrl/Cmd+S</kbd></td><td>Save Changes</td></tr>
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Quick Status (Ticket Page)</h4> </table>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;"> <h4 class="kb-section-heading">Quick Status (Ticket Page)</h4>
<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> <table class="kb-shortcuts-table">
<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><kbd>1</kbd></td><td>Set Open</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><kbd>2</kbd></td><td>Set Pending</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>4</kbd></td><td style="padding: 0.4rem;">Set Closed</td></tr> <tr><td><kbd>3</kbd></td><td>Set In Progress</td></tr>
</table> <tr><td><kbd>4</kbd></td><td>Set Closed</td></tr>
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Other</h4> </table>
<table style="width: 100%; border-collapse: collapse;"> <h4 class="kb-section-heading">Other</h4>
<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> <table class="kb-shortcuts-table no-margin">
<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><kbd>Ctrl/Cmd+K</kbd></td><td>Focus Search</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>?</kbd></td><td style="padding: 0.4rem;">Show This Help</td></tr> <tr><td><kbd>ESC</kbd></td><td>Close Modal / Cancel</td></tr>
</table> <tr><td><kbd>?</kbd></td><td>Show This Help</td></tr>
</div> </table>
<div class="modal-footer" style="margin-top: 1rem;"> </div>
<button class="btn btn-secondary" data-action="close-shortcuts-modal">Close</button> <div class="lt-modal-footer">
</div> <button class="lt-btn lt-btn-ghost" data-modal-close>CLOSE</button>
</div>
</div> </div>
</div> </div>
`; `;
document.body.appendChild(modal); document.body.appendChild(modal);
lt.modal.open('keyboardHelpModal');
// Add event listener for the close button
modal.querySelector('[data-action="close-shortcuts-modal"]').addEventListener('click', function() {
modal.remove();
});
} }
document.addEventListener('DOMContentLoaded', function() {
if (!window.lt) return;
// Ctrl+E: Toggle edit mode (ticket pages)
lt.keys.on('ctrl+e', function() {
const editButton = document.getElementById('editButton');
if (editButton) {
editButton.click();
lt.toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
}
});
// Ctrl+S: Save ticket (ticket pages)
lt.keys.on('ctrl+s', function() {
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
editButton.click();
lt.toast.success('Saving ticket...');
}
});
// ?: Show keyboard shortcuts help (lt.keys.initDefaults also handles this, but we override to show our modal)
lt.keys.on('?', function() {
showKeyboardHelp();
});
// J: Next row
lt.keys.on('j', () => navigateTableRow('next'));
// K: Previous row
lt.keys.on('k', () => navigateTableRow('prev'));
// Enter: Open selected ticket
lt.keys.on('enter', function() {
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
if (selectedRow) {
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
if (ticketLink) window.location.href = ticketLink.href;
}
});
// N: New ticket
lt.keys.on('n', function() {
const newTicketBtn = document.querySelector('a[href*="/create"]');
if (newTicketBtn) window.location.href = newTicketBtn.href;
});
// C: Focus comment box
lt.keys.on('c', function() {
const commentBox = document.getElementById('newComment');
if (commentBox) {
commentBox.focus();
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
// G then D: Go to Dashboard (vim-style)
lt.keys.on('g', function() {
window._pendingG = true;
setTimeout(() => { window._pendingG = false; }, 1000);
});
lt.keys.on('d', function() {
if (window._pendingG) {
window._pendingG = false;
window.location.href = '/';
}
});
// 1-4: Quick status change on ticket page
['1', '2', '3', '4'].forEach(key => {
lt.keys.on(key, function() {
const statusSelect = document.getElementById('statusSelect');
if (statusSelect && !document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) {
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
const targetStatus = statusMap[key];
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
if (option && !option.disabled) {
statusSelect.value = targetStatus;
statusSelect.dispatchEvent(new Event('change'));
}
}
});
});
});

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="list" data-textarea="${textareaId}" title="List">≡</button>
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button> <button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
<span class="toolbar-separator"></span> <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 // Add event delegation for toolbar buttons

View File

@@ -8,16 +8,13 @@ let userPreferences = {};
// Load preferences on page load // Load preferences on page load
async function loadUserPreferences() { async function loadUserPreferences() {
try { try {
const response = await fetch('/api/user_preferences.php', { const data = await lt.api.get('/api/user_preferences.php');
credentials: 'same-origin'
});
const data = await response.json();
if (data.success) { if (data.success) {
userPreferences = data.preferences; userPreferences = data.preferences;
applyPreferences(); applyPreferences();
} }
} catch (error) { } catch (error) {
console.error('Error loading preferences:', error); lt.toast.error('Error loading preferences');
} }
} }
@@ -94,34 +91,12 @@ async function saveSettings() {
}; };
try { try {
// Batch save all preferences in one request await lt.api.post('/api/user_preferences.php', { preferences: prefs });
const response = await fetch('/api/user_preferences.php', { lt.toast.success('Preferences saved successfully!');
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ preferences: prefs })
});
const result = await response.json();
if (!result.success) {
throw new Error('Failed to save preferences');
}
if (typeof toast !== 'undefined') {
toast.success('Preferences saved successfully!');
}
closeSettingsModal(); closeSettingsModal();
// Reload page to apply new preferences
setTimeout(() => window.location.reload(), 1000); setTimeout(() => window.location.reload(), 1000);
} catch (error) { } catch (error) {
if (typeof toast !== 'undefined') { lt.toast.error('Error saving preferences');
toast.error('Error saving preferences');
}
console.error('Error saving preferences:', error);
} }
} }
@@ -129,24 +104,18 @@ async function saveSettings() {
function openSettingsModal() { function openSettingsModal() {
const modal = document.getElementById('settingsModal'); const modal = document.getElementById('settingsModal');
if (modal) { if (modal) {
modal.style.display = 'flex'; lt.modal.open('settingsModal');
document.body.classList.add('modal-open');
loadUserPreferences(); loadUserPreferences();
} }
} }
function closeSettingsModal() { function closeSettingsModal() {
const modal = document.getElementById('settingsModal'); lt.modal.close('settingsModal');
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
} }
// Close modal when clicking on backdrop (outside the settings content) // Close modal when clicking on backdrop (outside the settings content)
function closeOnBackdropClick(event) { function closeOnBackdropClick(event) {
const modal = document.getElementById('settingsModal'); const modal = document.getElementById('settingsModal');
// Only close if clicking directly on the modal backdrop, not on content
if (event.target === modal) { if (event.target === modal) {
closeSettingsModal(); closeSettingsModal();
} }
@@ -158,15 +127,10 @@ document.addEventListener('keydown', (e) => {
e.preventDefault(); e.preventDefault();
openSettingsModal(); openSettingsModal();
} }
// ESC is handled globally by lt.keys.initDefaults()
// ESC to close modal
if (e.key === 'Escape') {
const modal = document.getElementById('settingsModal');
if (modal && modal.style.display !== 'none' && modal.style.display !== '') {
closeSettingsModal();
}
}
}); });
// Initialize on page load // Initialize on page load
document.addEventListener('DOMContentLoaded', loadUserPreferences); document.addEventListener('DOMContentLoaded', function() {
if (window.lt) loadUserPreferences();
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,94 +1,22 @@
/** /**
* Terminal-style toast notification system with queuing * Deprecated: use lt.toast.* directly (from web_template/base.js).
* This shim maintains backwards compatibility while callers are migrated.
*/ */
// Toast queue management // showToast() shim — used by inline view scripts
let toastQueue = []; function showToast(message, type = 'info', duration = 3500) {
let currentToast = null; switch (type) {
case 'success': lt.toast.success(message, duration); break;
function showToast(message, type = 'info', duration = 3000) { case 'error': lt.toast.error(message, duration); break;
// Queue if a toast is already showing case 'warning': lt.toast.warning(message, duration); break;
if (currentToast) { default: lt.toast.info(message, duration); break;
toastQueue.push({ message, type, duration });
return;
} }
displayToast(message, type, duration);
} }
function displayToast(message, type, duration) { // window.toast.* shim — used by JS files
// Create toast element
const toast = document.createElement('div');
toast.className = `terminal-toast toast-${type}`;
currentToast = toast;
// Icon based on type
const icons = {
success: '✓',
error: '✗',
info: '',
warning: '⚠'
};
const iconSpan = document.createElement('span');
iconSpan.className = 'toast-icon';
iconSpan.textContent = `[${icons[type] || ''}]`;
const msgSpan = document.createElement('span');
msgSpan.className = 'toast-message';
msgSpan.textContent = message;
const closeSpan = document.createElement('span');
closeSpan.className = 'toast-close';
closeSpan.style.cssText = 'margin-left: auto; cursor: pointer; opacity: 0.7; padding-left: 1rem;';
closeSpan.textContent = '[×]';
toast.appendChild(iconSpan);
toast.appendChild(msgSpan);
toast.appendChild(closeSpan);
// Add to document
document.body.appendChild(toast);
// Trigger animation
setTimeout(() => toast.classList.add('show'), 10);
// Manual dismiss handler
const closeBtn = toast.querySelector('.toast-close');
closeBtn.addEventListener('click', () => dismissToast(toast));
// Auto-remove after duration
const timeoutId = setTimeout(() => {
dismissToast(toast);
}, duration);
// Store timeout ID for manual dismiss
toast.timeoutId = timeoutId;
}
function dismissToast(toast) {
// Clear auto-dismiss timeout
if (toast.timeoutId) {
clearTimeout(toast.timeoutId);
}
toast.classList.remove('show');
setTimeout(() => {
toast.remove();
currentToast = null;
// Show next toast in queue
if (toastQueue.length > 0) {
const next = toastQueue.shift();
displayToast(next.message, next.type, next.duration);
}
}, 300);
}
// Convenience functions
window.toast = { window.toast = {
success: (msg, duration) => showToast(msg, 'success', duration), success: (msg, dur) => lt.toast.success(msg, dur),
error: (msg, duration) => showToast(msg, 'error', duration), error: (msg, dur) => lt.toast.error(msg, dur),
info: (msg, duration) => showToast(msg, 'info', duration), warning: (msg, dur) => lt.toast.warning(msg, dur),
warning: (msg, duration) => showToast(msg, 'warning', duration) info: (msg, dur) => lt.toast.info(msg, dur),
}; };

View File

@@ -1,8 +1,6 @@
// XSS prevention helper // XSS prevention helper — delegates to lt.escHtml() from web_template/base.js
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); return lt.escHtml(text);
div.textContent = text;
return div.innerHTML;
} }
// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats) // Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats)
@@ -12,3 +10,49 @@ function getTicketIdFromUrl() {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
return params.get('id'); 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

@@ -1,15 +0,0 @@
#!/bin/bash
set -e
echo "Deploying tinker_tickets to web server..."
# Deploy to web server
echo "Syncing to web server (10.10.10.45)..."
rsync -avz --delete --exclude='.git' --exclude='deploy.sh' --exclude='.env' ./ root@10.10.10.45:/var/www/html/tinkertickets/
# Set proper permissions on the web server
echo "Setting proper file permissions..."
ssh root@10.10.10.45 "chown -R www-data:www-data /var/www/html/tinkertickets && find /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/tinkertickets -type d -exec chmod 755 {} \;"
echo "Deployment to web server complete!"
echo "Don't forget to commit and push your changes via VS Code when ready."

View File

@@ -162,13 +162,5 @@ class Database {
return self::getConnection()->insert_id; return self::getConnection()->insert_id;
} }
/** // escape() removed — use prepared statements with bind_param() instead
* Escape a string for use in queries (prefer prepared statements)
*
* @param string $string String to escape
* @return string Escaped string
*/
public static function escape(string $string): string {
return self::getConnection()->real_escape_string($string);
}
} }

View File

@@ -42,8 +42,8 @@ if (!str_starts_with($requestPath, '/api/')) {
require_once 'models/UserPreferencesModel.php'; require_once 'models/UserPreferencesModel.php';
$prefsModel = new UserPreferencesModel($conn); $prefsModel = new UserPreferencesModel($conn);
$userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null); $userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null);
if ($userTimezone) { if ($userTimezone && in_array($userTimezone, DateTimeZone::listIdentifiers())) {
// Override system timezone with user preference // Override system timezone with user preference (validated against known identifiers)
date_default_timezone_set($userTimezone); date_default_timezone_set($userTimezone);
$GLOBALS['config']['TIMEZONE'] = $userTimezone; $GLOBALS['config']['TIMEZONE'] = $userTimezone;
$now = new DateTime('now', new DateTimeZone($userTimezone)); $now = new DateTime('now', new DateTimeZone($userTimezone));

View File

@@ -3,22 +3,11 @@
* AttachmentModel - Handles ticket file attachments * AttachmentModel - Handles ticket file attachments
*/ */
require_once __DIR__ . '/../config/config.php';
class AttachmentModel { class AttachmentModel {
private $conn; private $conn;
public function __construct() { public function __construct($conn) {
$this->conn = new mysqli( $this->conn = $conn;
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($this->conn->connect_error) {
throw new Exception('Database connection failed: ' . $this->conn->connect_error);
}
} }
/** /**
@@ -204,9 +193,4 @@ class AttachmentModel {
return in_array($mimeType, $allowedTypes); return in_array($mimeType, $allowedTypes);
} }
public function __destruct() {
if ($this->conn) {
$this->conn->close();
}
}
} }

View File

@@ -19,6 +19,14 @@ class BulkOperationsModel {
* @return int|false Operation ID or false on failure * @return int|false Operation ID or false on failure
*/ */
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) { public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) {
// Validate ticket IDs to prevent injection via implode
$ticketIds = array_values(array_filter(
array_map('strval', $ticketIds),
fn($id) => preg_match('/^[0-9]+$/', $id)
));
if (empty($ticketIds)) {
return false;
}
$ticketIdsStr = implode(',', $ticketIds); $ticketIdsStr = implode(',', $ticketIds);
$totalTickets = count($ticketIds); $totalTickets = count($ticketIds);
$parametersJson = $parameters ? json_encode($parameters) : null; $parametersJson = $parameters ? json_encode($parameters) : null;

View File

@@ -71,7 +71,7 @@ class CommentModel {
} }
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId); $stmt->bind_param("i", $ticketId);
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();
@@ -126,7 +126,8 @@ class CommentModel {
private function buildCommentThread($comment, &$allComments) { private function buildCommentThread($comment, &$allComments) {
$comment['replies'] = []; $comment['replies'] = [];
foreach ($allComments as $c) { foreach ($allComments as $c) {
if ($c['parent_comment_id'] == $comment['comment_id']) { if ($c['parent_comment_id'] == $comment['comment_id']
&& isset($allComments[$c['comment_id']])) {
$comment['replies'][] = $this->buildCommentThread($c, $allComments); $comment['replies'][] = $this->buildCommentThread($c, $allComments);
} }
} }

View File

@@ -54,29 +54,36 @@ class SavedFiltersModel {
* Save a new filter * Save a new filter
*/ */
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) { public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) {
// If this is set as default, unset all other defaults for this user $this->conn->begin_transaction();
if ($isDefault) { try {
$this->clearDefaultFilters($userId); // If this is set as default, unset all other defaults for this user
if ($isDefault) {
$this->clearDefaultFilters($userId);
}
$sql = "INSERT INTO saved_filters (user_id, filter_name, filter_criteria, is_default)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
filter_criteria = VALUES(filter_criteria),
is_default = VALUES(is_default),
updated_at = CURRENT_TIMESTAMP";
$stmt = $this->conn->prepare($sql);
$criteriaJson = json_encode($filterCriteria);
$stmt->bind_param("issi", $userId, $filterName, $criteriaJson, $isDefault);
if ($stmt->execute()) {
$filterId = $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName);
$this->conn->commit();
return ['success' => true, 'filter_id' => $filterId];
}
$error = $this->conn->error;
$this->conn->rollback();
return ['success' => false, 'error' => $error];
} catch (Exception $e) {
$this->conn->rollback();
return ['success' => false, 'error' => $e->getMessage()];
} }
$sql = "INSERT INTO saved_filters (user_id, filter_name, filter_criteria, is_default)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
filter_criteria = VALUES(filter_criteria),
is_default = VALUES(is_default),
updated_at = CURRENT_TIMESTAMP";
$stmt = $this->conn->prepare($sql);
$criteriaJson = json_encode($filterCriteria);
$stmt->bind_param("issi", $userId, $filterName, $criteriaJson, $isDefault);
if ($stmt->execute()) {
return [
'success' => true,
'filter_id' => $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName)
];
}
return ['success' => false, 'error' => $this->conn->error];
} }
/** /**
@@ -126,18 +133,25 @@ class SavedFiltersModel {
* Set a filter as default * Set a filter as default
*/ */
public function setDefaultFilter($filterId, $userId) { public function setDefaultFilter($filterId, $userId) {
// First, clear all defaults $this->conn->begin_transaction();
$this->clearDefaultFilters($userId); try {
$this->clearDefaultFilters($userId);
// Then set this one as default $sql = "UPDATE saved_filters SET is_default = 1 WHERE filter_id = ? AND user_id = ?";
$sql = "UPDATE saved_filters SET is_default = 1 WHERE filter_id = ? AND user_id = ?"; $stmt = $this->conn->prepare($sql);
$stmt = $this->conn->prepare($sql); $stmt->bind_param("ii", $filterId, $userId);
$stmt->bind_param("ii", $filterId, $userId);
if ($stmt->execute()) { if ($stmt->execute()) {
return ['success' => true]; $this->conn->commit();
return ['success' => true];
}
$error = $this->conn->error;
$this->conn->rollback();
return ['success' => false, 'error' => $error];
} catch (Exception $e) {
$this->conn->rollback();
return ['success' => false, 'error' => $e->getMessage()];
} }
return ['success' => false, 'error' => $this->conn->error];
} }
/** /**

View File

@@ -134,7 +134,7 @@ class StatsModel {
u.username, u.username,
COUNT(t.ticket_id) as ticket_count COUNT(t.ticket_id) as ticket_count
FROM tickets t FROM tickets t
JOIN users u ON t.assigned_to = u.user_id LEFT JOIN users u ON t.assigned_to = u.user_id
WHERE t.status != 'Closed' WHERE t.status != 'Closed'
GROUP BY t.assigned_to GROUP BY t.assigned_to
ORDER BY ticket_count DESC ORDER BY ticket_count DESC

View File

@@ -422,6 +422,34 @@ class TicketModel {
'ticket_id' => $ticket_id 'ticket_id' => $ticket_id
]; ];
} else { } else {
// Handle duplicate key (errno 1062) caused by race condition between
// the uniqueness SELECT above and this INSERT — regenerate and retry once
if ($this->conn->errno === 1062) {
$stmt->close();
try {
$ticket_id = sprintf('%09d', random_int(100000000, 999999999));
} catch (Exception $e) {
$ticket_id = sprintf('%09d', mt_rand(100000000, 999999999));
}
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
"sssssssiiss",
$ticket_id,
$ticketData['title'],
$ticketData['description'],
$status,
$priority,
$category,
$type,
$createdBy,
$assignedTo,
$visibility,
$visibilityGroups
);
if ($stmt->execute()) {
return ['success' => true, 'ticket_id' => $ticket_id];
}
}
return [ return [
'success' => false, 'success' => false,
'error' => $this->conn->error 'error' => $this->conn->error

View File

@@ -27,6 +27,10 @@ class WorkflowModel {
WHERE is_active = TRUE"; WHERE is_active = TRUE";
$result = $this->conn->query($sql); $result = $this->conn->query($sql);
if (!$result) {
return [];
}
$transitions = []; $transitions = [];
while ($row = $result->fetch_assoc()) { while ($row = $result->fetch_assoc()) {
$from = $row['from_status']; $from = $row['from_status'];
@@ -102,6 +106,10 @@ class WorkflowModel {
ORDER BY status"; ORDER BY status";
$result = $this->conn->query($sql); $result = $this->conn->query($sql);
if (!$result) {
return [];
}
$statuses = []; $statuses = [];
while ($row = $result->fetch_assoc()) { while ($row = $result->fetch_assoc()) {
$statuses[] = $row['status']; $statuses[] = $row['status'];

View File

@@ -11,10 +11,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create New Ticket</title> <title>Create New Ticket</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c"> <link rel="stylesheet" href="/assets/css/base.css">
<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">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script> <script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests // CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
@@ -23,13 +25,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<body> <body>
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a> <a href="/" class="back-link">[ &larr; DASHBOARD ]</a>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?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']): ?> <?php if ($GLOBALS['currentUser']['is_admin']): ?>
<span class="admin-badge">Admin</span> <span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
@@ -46,7 +48,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<div class="ticket-header"> <div class="ticket-header">
<h2>New Ticket Form</h2> <h2>New Ticket Form</h2>
<p style="color: var(--terminal-green); font-family: var(--font-mono); font-size: 0.9rem; margin-top: 0.5rem;"> <p class="form-hint">
Complete the form below to create a new ticket Complete the form below to create a new ticket
</p> </p>
</div> </div>
@@ -60,8 +62,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<!-- ERROR SECTION --> <!-- ERROR SECTION -->
<div class="ascii-content"> <div class="ascii-content">
<div class="ascii-frame-inner"> <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);"> <div class="error-message inline-error">
<strong>⚠ Error:</strong> <?php echo $error; ?> <strong>[ ! ] ERROR:</strong> <?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?>
</div> </div>
</div> </div>
</div> </div>
@@ -88,7 +90,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</select> </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 Select a template to auto-fill form fields
</p> </p>
</div> </div>
@@ -107,11 +109,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket"> <input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
</div> </div>
<!-- Duplicate Warning Area --> <!-- 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 id="duplicateWarning" class="inline-warning is-hidden" role="alert" aria-live="polite" aria-atomic="true">
<div style="color: var(--terminal-amber); font-weight: bold; margin-bottom: 0.5rem;"> <div class="text-amber fw-bold duplicate-heading">
Possible Duplicates Found Possible Duplicates Found
</div> </div>
<div id="duplicatesList"></div> <div id="duplicatesList" aria-live="polite"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -183,7 +185,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</select> </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 Select a user to assign this ticket to
</p> </p>
</div> </div>
@@ -204,13 +206,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="internal">Internal - Specific groups only</option> <option value="internal">Internal - Specific groups only</option>
<option value="confidential">Confidential - Creator, assignee, admins only</option> <option value="confidential">Confidential - Creator, assignee, admins only</option>
</select> </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 Controls who can view this ticket
</p> </p>
</div> </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> <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 <?php
// Get all available groups // Get all available groups
require_once __DIR__ . '/../models/UserModel.php'; require_once __DIR__ . '/../models/UserModel.php';
@@ -218,16 +220,16 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$allGroups = $userModel->getAllGroups(); $allGroups = $userModel->getAllGroups();
foreach ($allGroups as $group): 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"> <input type="checkbox" name="visibility_groups[]" value="<?php echo htmlspecialchars($group); ?>" class="visibility-group-checkbox">
<span class="group-badge"><?php echo htmlspecialchars($group); ?></span> <span class="group-badge"><?php echo htmlspecialchars($group); ?></span>
</label> </label>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($allGroups)): ?> <?php if (empty($allGroups)): ?>
<span style="color: var(--text-muted);">No groups available</span> <span class="text-muted">No groups available</span>
<?php endif; ?> <?php endif; ?>
</div> </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 Select which groups can view this ticket
</p> </p>
</div> </div>
@@ -256,8 +258,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ascii-content"> <div class="ascii-content">
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<div class="ticket-footer"> <div class="ticket-footer">
<button type="submit" class="btn primary">Create Ticket</button> <button type="submit" class="btn primary">CREATE TICKET</button>
<button type="button" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">Cancel</button> <button type="button" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">CANCEL</button>
</div> </div>
</div> </div>
</div> </div>
@@ -275,7 +277,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
const title = this.value.trim(); const title = this.value.trim();
if (title.length < 5) { if (title.length < 5) {
document.getElementById('duplicateWarning').style.display = 'none'; document.getElementById('duplicateWarning').classList.add('is-hidden');
return; return;
} }
@@ -286,30 +288,29 @@ $nonce = SecurityHeadersMiddleware::getNonce();
}); });
function checkForDuplicates(title) { function checkForDuplicates(title) {
fetch('/api/check_duplicates.php?title=' + encodeURIComponent(title)) lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
.then(response => response.json())
.then(data => { .then(data => {
const warningDiv = document.getElementById('duplicateWarning'); const warningDiv = document.getElementById('duplicateWarning');
const listDiv = document.getElementById('duplicatesList'); const listDiv = document.getElementById('duplicatesList');
if (data.success && data.duplicates && data.duplicates.length > 0) { 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 => { data.duplicates.forEach(dup => {
html += `<li style="margin-bottom: 0.5rem;"> html += `<li>
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank" style="color: var(--terminal-green);"> <a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank">
#${escapeHtml(dup.ticket_id)} #${escapeHtml(dup.ticket_id)}
</a> </a>
- ${escapeHtml(dup.title)} - ${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>`; </li>`;
}); });
html += '</ul>'; 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; listDiv.innerHTML = html;
warningDiv.style.display = 'block'; warningDiv.classList.remove('is-hidden');
} else { } else {
warningDiv.style.display = 'none'; warningDiv.classList.add('is-hidden');
} }
}) })
.catch(error => { .catch(error => {
@@ -321,9 +322,9 @@ $nonce = SecurityHeadersMiddleware::getNonce();
const visibility = document.getElementById('visibility').value; const visibility = document.getElementById('visibility').value;
const groupsContainer = document.getElementById('visibilityGroupsContainer'); const groupsContainer = document.getElementById('visibilityGroupsContainer');
if (visibility === 'internal') { if (visibility === 'internal') {
groupsContainer.style.display = 'block'; groupsContainer.classList.remove('is-hidden');
} else { } else {
groupsContainer.style.display = 'none'; groupsContainer.classList.add('is-hidden');
document.querySelectorAll('.visibility-group-checkbox').forEach(cb => cb.checked = false); document.querySelectorAll('.visibility-group-checkbox').forEach(cb => cb.checked = false);
} }
} }
@@ -350,6 +351,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
toggleVisibilityGroups(); toggleVisibilityGroups();
} }
}); });
if (window.lt) lt.keys.initDefaults();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -12,60 +12,64 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ticket Dashboard</title> <title>Ticket Dashboard</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e"> <link rel="stylesheet" href="/assets/css/base.css">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script> <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/ascii-banner.js?v=20260320"></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/utils.js?v=20260320"></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/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; ?>"> <script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests // CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
// Timezone configuration (from server) // Timezone configuration (from server)
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>'; window.APP_TIMEZONE = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC window.APP_TIMEZONE_OFFSET = <?php echo (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0); ?>; // minutes from UTC
window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>'; window.APP_TIMEZONE_ABBREV = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE_ABBREV'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
</script> </script>
</head> </head>
<body data-categories='<?php echo json_encode($categories); ?>' data-types='<?php echo json_encode($types); ?>'> <body data-categories='<?php echo htmlspecialchars(json_encode($categories), ENT_QUOTES, 'UTF-8'); ?>' data-types='<?php echo htmlspecialchars(json_encode($types), ENT_QUOTES, 'UTF-8'); ?>'>
<!-- Terminal Boot Sequence --> <!-- Terminal Boot Sequence -->
<div id="boot-sequence" class="boot-overlay"> <div id="boot-sequence" class="boot-overlay">
<div id="boot-banner"></div>
<pre id="boot-text"></pre> <pre id="boot-text"></pre>
</div> </div>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
function showBootSequence() { function showBootSequence() {
const bootText = document.getElementById('boot-text'); const bootText = document.getElementById('boot-text');
const bootOverlay = document.getElementById('boot-sequence'); const bootOverlay = document.getElementById('boot-sequence');
// Render ASCII banner first, then start boot messages
renderResponsiveBanner('#boot-banner', 0);
const messages = [ const messages = [
'╔═══════════════════════════════════════╗',
'║ TINKER TICKETS TERMINAL v1.0 ║',
'║ BOOTING SYSTEM... ║',
'╚═══════════════════════════════════════╝',
'',
'[ OK ] Loading kernel modules...', '[ OK ] Loading kernel modules...',
'[ OK ] Initializing ticket database...', '[ OK ] Initializing ticket database...',
'[ OK ] Mounting user session...', '[ OK ] Mounting user session...',
'[ OK ] Starting dashboard services...', '[ OK ] Starting dashboard services...',
'[ OK ] Rendering ASCII frames...', '[ OK ] Rendering ASCII frames...',
'', '',
'> SYSTEM READY ', '> SYSTEM READY [OK]',
'' ''
]; ];
let i = 0; let i = 0;
const interval = setInterval(() => { // Brief pause after banner renders before boot text begins
if (i < messages.length) { setTimeout(() => {
bootText.textContent += messages[i] + '\n'; const interval = setInterval(() => {
i++; if (i < messages.length) {
} else { bootText.textContent += messages[i] + '\n';
setTimeout(() => { i++;
bootOverlay.style.opacity = '0'; } else {
setTimeout(() => bootOverlay.remove(), 500); setTimeout(() => {
}, 500); bootOverlay.classList.add('boot-overlay--fade-out');
clearInterval(interval); setTimeout(() => bootOverlay.remove(), 500);
} }, 500);
}, 80); clearInterval(interval);
}
}, 80);
}, 400);
} }
// Run on first visit only (per session) // Run on first visit only (per session)
@@ -78,52 +82,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</script> </script>
<header class="user-header" role="banner"> <header class="user-header" role="banner">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="app-title">🎫 Tinker Tickets</a> <a href="/" class="app-title">[ TINKER TICKETS ]</a>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?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']): ?> <?php if ($GLOBALS['currentUser']['is_admin']): ?>
<div class="admin-dropdown"> <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"> <div class="admin-dropdown-content" id="adminDropdown">
<a href="/admin/templates">📋 Templates</a> <a href="/admin/templates">TEMPLATES</a>
<a href="/admin/workflow">🔄 Workflow</a> <a href="/admin/workflow">WORKFLOW</a>
<a href="/admin/recurring-tickets">🔁 Recurring Tickets</a> <a href="/admin/recurring-tickets">RECURRING</a>
<a href="/admin/custom-fields">📝 Custom Fields</a> <a href="/admin/custom-fields">CUSTOM FIELDS</a>
<a href="/admin/user-activity">👥 User Activity</a> <a href="/admin/user-activity">USER ACTIVITY</a>
<a href="/admin/audit-log">📜 Audit Log</a> <a href="/admin/audit-log">AUDIT LOG</a>
<a href="/admin/api-keys">🔑 API Keys</a> <a href="/admin/api-keys">API KEYS</a>
</div> </div>
</div> </div>
<?php endif; ?> <?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; ?> <?php endif; ?>
</div> </div>
</header> </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 --> <!-- Dashboard Layout with Sidebar -->
<div class="dashboard-layout" id="dashboardLayout"> <div class="dashboard-layout" id="dashboardLayout">
<!-- Left Sidebar with Filters --> <!-- Left Sidebar with Filters -->
@@ -161,7 +144,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label> <label>
<input type="checkbox" <input type="checkbox"
name="category" name="category"
value="<?php echo $cat; ?>" value="<?php echo htmlspecialchars($cat); ?>"
<?php echo in_array($cat, $currentCategories) ? 'checked' : ''; ?>> <?php echo in_array($cat, $currentCategories) ? 'checked' : ''; ?>>
<?php echo htmlspecialchars($cat); ?> <?php echo htmlspecialchars($cat); ?>
</label> </label>
@@ -178,15 +161,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label> <label>
<input type="checkbox" <input type="checkbox"
name="type" name="type"
value="<?php echo $type; ?>" value="<?php echo htmlspecialchars($type); ?>"
<?php echo in_array($type, $currentTypes) ? 'checked' : ''; ?>> <?php echo in_array($type, $currentTypes) ? 'checked' : ''; ?>>
<?php echo htmlspecialchars($type); ?> <?php echo htmlspecialchars($type); ?>
</label> </label>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<button id="apply-filters-btn" class="btn">Apply Filters</button> <button id="apply-filters-btn" class="btn">APPLY FILTERS</button>
<button id="clear-filters-btn" class="btn btn-secondary">Clear All</button> <button id="clear-filters-btn" class="btn btn-secondary">CLEAR ALL</button>
</div> </div>
</div> </div>
</aside> </aside>
@@ -201,42 +184,42 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="stats-widgets"> <div class="stats-widgets">
<div class="stats-row"> <div class="stats-row">
<div class="stat-card stat-open"> <div class="stat-card stat-open">
<div class="stat-icon">📂</div> <div class="stat-icon">[ # ]</div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-value"><?php echo $stats['open_tickets']; ?></div> <div class="stat-value"><?php echo $stats['open_tickets']; ?></div>
<div class="stat-label">Open Tickets</div> <div class="stat-label">Open Tickets</div>
</div> </div>
</div> </div>
<div class="stat-card stat-critical"> <div class="stat-card stat-critical">
<div class="stat-icon">🔥</div> <div class="stat-icon">[ ! ]</div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-value"><?php echo $stats['critical']; ?></div> <div class="stat-value"><?php echo $stats['critical']; ?></div>
<div class="stat-label">Critical (P1)</div> <div class="stat-label">Critical (P1)</div>
</div> </div>
</div> </div>
<div class="stat-card stat-unassigned"> <div class="stat-card stat-unassigned">
<div class="stat-icon">👤</div> <div class="stat-icon">[ @ ]</div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-value"><?php echo $stats['unassigned']; ?></div> <div class="stat-value"><?php echo $stats['unassigned']; ?></div>
<div class="stat-label">Unassigned</div> <div class="stat-label">Unassigned</div>
</div> </div>
</div> </div>
<div class="stat-card stat-today"> <div class="stat-card stat-today">
<div class="stat-icon">📅</div> <div class="stat-icon">[ + ]</div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-value"><?php echo $stats['created_today']; ?></div> <div class="stat-value"><?php echo $stats['created_today']; ?></div>
<div class="stat-label">Created Today</div> <div class="stat-label">Created Today</div>
</div> </div>
</div> </div>
<div class="stat-card stat-resolved"> <div class="stat-card stat-resolved">
<div class="stat-icon"></div> <div class="stat-icon">[ OK ]</div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-value"><?php echo $stats['closed_today']; ?></div> <div class="stat-value"><?php echo $stats['closed_today']; ?></div>
<div class="stat-label">Closed Today</div> <div class="stat-label">Closed Today</div>
</div> </div>
</div> </div>
<div class="stat-card stat-time"> <div class="stat-card stat-time">
<div class="stat-icon"></div> <div class="stat-icon">[ t ]</div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-value"><?php echo $stats['avg_resolution_hours']; ?>h</div> <div class="stat-value"><?php echo $stats['avg_resolution_hours']; ?>h</div>
<div class="stat-label">Avg Resolution</div> <div class="stat-label">Avg Resolution</div>
@@ -250,7 +233,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="dashboard-toolbar"> <div class="dashboard-toolbar">
<!-- Left: Title + Search --> <!-- Left: Title + Search -->
<div class="toolbar-left"> <div class="toolbar-left">
<h1 class="dashboard-title">🎫 Tickets</h1> <h1 class="dashboard-title">[ TICKETS ]</h1>
<form method="GET" action="" class="toolbar-search"> <form method="GET" action="" class="toolbar-search">
<!-- Preserve existing parameters --> <!-- Preserve existing parameters -->
<?php if (isset($_GET['status'])): ?> <?php if (isset($_GET['status'])): ?>
@@ -271,13 +254,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<input type="text" <input type="text"
name="search" name="search"
placeholder="🔍 Search tickets..." placeholder="&gt; Search tickets..."
class="search-box" class="search-box"
value="<?php echo isset($_GET['search']) ? htmlspecialchars($_GET['search']) : ''; ?>"> value="<?php echo isset($_GET['search']) ? htmlspecialchars($_GET['search']) : ''; ?>">
<button type="submit" class="btn search-btn">Search</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">⚙ Advanced</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'])): ?> <?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; ?> <?php endif; ?>
</form> </form>
</div> </div>
@@ -285,12 +268,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<!-- Center: Actions + Count --> <!-- Center: Actions + Count -->
<div class="toolbar-center"> <div class="toolbar-center">
<div class="view-toggle"> <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="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="cardViewBtn" class="view-btn" data-action="set-view-mode" data-mode="card" title="Kanban View" aria-label="Kanban view" aria-pressed="false">[ # ]</button>
</div> </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;"> <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"> <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="csv">CSV</a>
<a href="#" data-action="export-tickets" data-format="json">JSON</a> <a href="#" data-action="export-tickets" data-format="json">JSON</a>
@@ -308,23 +291,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
// Previous page button // Previous page button
if ($page > 1) { if ($page > 1) {
$currentParams['page'] = $page - 1; $currentParams['page'] = $page - 1;
$prevUrl = '?' . http_build_query($currentParams); $prevUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
echo "<button data-action='navigate' data-url='$prevUrl'</button>"; echo "<button data-action='navigate' data-url='$prevUrl' aria-label='Previous page'>[ « ]</button>";
} }
// Page number buttons // Page number buttons
for ($i = 1; $i <= $totalPages; $i++) { for ($i = 1; $i <= $totalPages; $i++) {
$activeClass = ($i === $page) ? 'active' : ''; $activeClass = ($i === $page) ? 'active' : '';
$currentParams['page'] = $i; $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>"; echo "<button class='$activeClass' data-action='navigate' data-url='$pageUrl'>$i</button>";
} }
// Next page button // Next page button
if ($page < $totalPages) { if ($page < $totalPages) {
$currentParams['page'] = $page + 1; $currentParams['page'] = $page + 1;
$nextUrl = '?' . http_build_query($currentParams); $nextUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
echo "<button data-action='navigate' data-url='$nextUrl'</button>"; echo "<button data-action='navigate' data-url='$nextUrl' aria-label='Next page'>[ » ]</button>";
} }
?> ?>
</div> </div>
@@ -350,10 +333,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?> <?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
<div class="bulk-actions-inline" style="display: none;"> <div class="bulk-actions-inline" style="display: none;">
<span id="selected-count">0</span> tickets selected <span id="selected-count">0</span> tickets selected
<button data-action="bulk-status" class="btn btn-bulk">Change Status</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-assign" class="btn btn-bulk">ASSIGN</button>
<button data-action="bulk-priority" class="btn btn-bulk">Priority</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="clear-selection" class="btn btn-secondary">CLEAR</button>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -392,11 +375,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php foreach ($activeFilters as $filter): ?> <?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']); ?>"> <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']); ?> <?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> </span>
<?php endforeach; ?> <?php endforeach; ?>
</div> </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> </div>
<?php endif; ?> <?php endif; ?>
@@ -406,11 +389,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<thead> <thead>
<tr> <tr>
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?> <?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 endif; ?>
<?php <?php
$currentSort = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id'; $currentSort = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
$currentDir = isset($_GET['dir']) ? $_GET['dir'] : 'desc'; $currentDir = (isset($_GET['dir']) && $_GET['dir'] === 'asc') ? 'asc' : 'desc';
$columns = [ $columns = [
'ticket_id' => 'Ticket ID', 'ticket_id' => 'Ticket ID',
@@ -428,13 +411,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
foreach($columns as $col => $label) { foreach($columns as $col => $label) {
if ($col === '_actions') { 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 { } else {
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc'; $newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
$sortClass = ($currentSort === $col) ? "sort-$currentDir" : ''; $sortClass = ($currentSort === $col) ? "sort-$currentDir" : '';
$ariaSort = ($currentSort === $col) ? "aria-sort='" . ($currentDir === 'asc' ? 'ascending' : 'descending') . "'" : '';
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]); $sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]);
$sortUrl = '?' . http_build_query($sortParams); $sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
echo "<th class='$sortClass' data-action='navigate' data-url='$sortUrl'>$label</th>"; echo "<th scope='col' class='$sortClass' data-action='navigate' data-url='$sortUrl' $ariaSort>$label</th>";
} }
} }
?> ?>
@@ -450,33 +434,34 @@ $nonce = SecurityHeadersMiddleware::getNonce();
// Add checkbox column for admins // Add checkbox column for admins
if ($GLOBALS['currentUser']['is_admin'] ?? false) { 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><a href='/ticket/{$row['ticket_id']}' class='ticket-link'>{$row['ticket_id']}</a></td>";
echo "<td><span>{$row['priority']}</span></td>"; echo "<td><span>{$row['priority']}</span></td>";
echo "<td>" . htmlspecialchars($row['title']) . "</td>"; echo "<td>" . htmlspecialchars($row['title']) . "</td>";
echo "<td>{$row['category']}</td>"; echo "<td>" . htmlspecialchars($row['category']) . "</td>";
echo "<td>{$row['type']}</td>"; echo "<td>" . htmlspecialchars($row['type']) . "</td>";
echo "<td><span class='status-" . str_replace(' ', '-', $row['status']) . "'>{$row['status']}</span></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($creator) . "</td>";
echo "<td>" . htmlspecialchars($assignedTo) . "</td>"; echo "<td>" . htmlspecialchars($assignedTo) . "</td>";
echo "<td>" . date('Y-m-d H:i', strtotime($row['created_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>" . date('Y-m-d H:i', strtotime($row['updated_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 // Quick actions column
echo "<td class='quick-actions-cell'>"; echo "<td class='quick-actions-cell'>";
echo "<div class='quick-actions'>"; 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='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='{$row['ticket_id']}' data-status='{$row['status']}' class='quick-action-btn' title='Change Status'>🔄</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'>👤</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 "</div>";
echo "</td>"; echo "</td>";
echo "</tr>"; echo "</tr>";
} }
} else { } else {
$colspan = ($GLOBALS['currentUser']['is_admin'] ?? false) ? '12' : '11'; $colspan = ($GLOBALS['currentUser']['is_admin'] ?? false) ? '12' : '11';
echo "<tr><td colspan='$colspan' style='text-align: center; padding: 3rem;'>"; echo "<tr><td colspan='$colspan' class='dashboard-empty-state'>";
echo "<pre style='color: var(--terminal-green); text-shadow: var(--glow-green); font-size: 0.8rem; line-height: 1.2;'>"; echo "<pre class='dashboard-empty-pre'>";
echo "╔════════════════════════════════════════╗\n"; echo "╔════════════════════════════════════════╗\n";
echo "║ ║\n"; echo "║ ║\n";
echo "║ NO TICKETS FOUND ║\n"; echo "║ NO TICKETS FOUND ║\n";
@@ -507,17 +492,17 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ticket-card-main"> <div class="ticket-card-main">
<div class="ticket-card-title"><?php echo htmlspecialchars($row['title']); ?></div> <div class="ticket-card-title"><?php echo htmlspecialchars($row['title']); ?></div>
<div class="ticket-card-meta"> <div class="ticket-card-meta">
<span>📁 <?php echo htmlspecialchars($row['category']); ?></span> <span><?php echo htmlspecialchars($row['category']); ?></span>
<span>👤 <?php echo htmlspecialchars($assignedTo); ?></span> <span>@ <?php echo htmlspecialchars($assignedTo); ?></span>
<span>📅 <?php echo date('M j', strtotime($row['updated_at'])); ?></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> </div>
<div class="ticket-card-status <?php echo $statusClass; ?>"> <div class="ticket-card-status <?php echo htmlspecialchars($statusClass); ?>">
<?php echo $row['status']; ?> <?php echo htmlspecialchars($row['status']); ?>
</div> </div>
<div class="ticket-card-actions"> <div class="ticket-card-actions">
<button data-action="view-ticket" data-ticket-id="<?php echo $row['ticket_id']; ?>" title="View">👁</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 $row['ticket_id']; ?>" data-status="<?php echo $row['status']; ?>" title="Status">🔄</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>
</div> </div>
<?php <?php
@@ -539,7 +524,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<!-- END OUTER FRAME --> <!-- END OUTER FRAME -->
<!-- Kanban Card View --> <!-- 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-board">
<div class="kanban-column" data-status="Open"> <div class="kanban-column" data-status="Open">
<div class="kanban-column-header status-Open"> <div class="kanban-column-header status-Open">
@@ -573,17 +558,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</section> </section>
<!-- Settings Modal --> <!-- Settings Modal -->
<div class="settings-modal" id="settingsModal" style="display: none;" data-action="close-settings-backdrop" role="dialog" aria-modal="true" aria-labelledby="settingsModalTitle"> <div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="settingsModalTitle">
<div class="settings-content"> <div class="lt-modal">
<span class="bottom-left-corner">╚</span> <div class="lt-modal-header">
<span class="bottom-right-corner">╝</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 class="settings-header">
<h3 id="settingsModalTitle">⚙ System Preferences</h3>
<button class="close-settings" data-action="close-settings" aria-label="Close settings">✗</button>
</div> </div>
<div class="settings-body"> <div class="lt-modal-body">
<!-- Display Preferences --> <!-- Display Preferences -->
<div class="settings-section"> <div class="settings-section">
<h4>╔══ Display Preferences ══╗</h4> <h4>╔══ Display Preferences ══╗</h4>
@@ -724,23 +706,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<div class="settings-footer"> <div class="lt-modal-footer">
<button class="btn btn-primary" data-action="save-settings">Save Preferences</button> <button class="lt-btn lt-btn-primary" data-action="save-settings">SAVE PREFERENCES</button>
<button class="btn btn-secondary" data-action="close-settings">Cancel</button> <button class="lt-btn lt-btn-ghost" data-action="close-settings">CANCEL</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Advanced Search Modal --> <!-- Advanced Search Modal -->
<div class="settings-modal" id="advancedSearchModal" style="display: none;" data-action="close-advanced-search-backdrop" role="dialog" aria-modal="true" aria-labelledby="advancedSearchModalTitle"> <div class="lt-modal-overlay" id="advancedSearchModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="advancedSearchModalTitle">
<div class="settings-content"> <div class="lt-modal">
<div class="settings-header"> <div class="lt-modal-header">
<h3 id="advancedSearchModalTitle">🔍 Advanced Search</h3> <span class="lt-modal-title" id="advancedSearchModalTitle">[ FILTER ] ADVANCED SEARCH</span>
<button class="close-settings" data-action="close-advanced-search" aria-label="Close advanced search"></button> <button class="lt-modal-close" data-modal-close aria-label="Close advanced search"></button>
</div> </div>
<form id="advancedSearchForm"> <form id="advancedSearchForm">
<div class="settings-body"> <div class="lt-modal-body">
<!-- Saved Filters --> <!-- Saved Filters -->
<div class="settings-section"> <div class="settings-section">
<h4>╔══ Saved Filters ══╗</h4> <h4>╔══ Saved Filters ══╗</h4>
@@ -751,8 +733,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</select> </select>
</div> </div>
<div class="setting-row setting-row-right"> <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="save-filter">SAVE</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="delete-filter">DELETE</button>
</div> </div>
</div> </div>
@@ -841,19 +823,21 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<div class="settings-footer"> <div class="lt-modal-footer">
<button type="submit" class="btn btn-primary">Search</button> <button type="submit" class="lt-btn lt-btn-primary">SEARCH</button>
<button type="button" class="btn btn-secondary" data-action="reset-advanced-search">Reset</button> <button type="button" class="lt-btn lt-btn-ghost" data-action="reset-advanced-search">RESET</button>
<button type="button" class="btn btn-secondary" data-action="close-advanced-search">Cancel</button> <button type="button" class="lt-btn lt-btn-ghost" data-action="close-advanced-search">CANCEL</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js?v=20260320"></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/keyboard-shortcuts.js?v=20260320"></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/advanced-search.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
// Initialize lt keyboard defaults (ESC closes modals, Ctrl+K focuses search, ? shows help)
if (window.lt) lt.keys.initDefaults();
// Event delegation for all data-action handlers // Event delegation for all data-action handlers
document.addEventListener('click', function(event) { document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]'); const target = event.target.closest('[data-action]');
@@ -878,21 +862,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
openSettingsModal(); openSettingsModal();
break; break;
case 'close-settings': case 'manual-refresh':
closeSettingsModal(); lt.autoRefresh.now();
break; break;
case 'close-settings-backdrop': case 'close-settings':
if (event.target === target) closeSettingsModal(); closeSettingsModal();
break; break;
case 'save-settings': case 'save-settings':
saveSettings(); saveSettings();
break; break;
case 'toggle-banner':
toggleBanner();
break;
case 'toggle-sidebar': case 'toggle-sidebar':
toggleSidebar(); toggleSidebar();
@@ -906,10 +887,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
closeAdvancedSearch(); closeAdvancedSearch();
break; break;
case 'close-advanced-search-backdrop':
if (event.target === target) closeAdvancedSearch();
break;
case 'reset-advanced-search': case 'reset-advanced-search':
resetAdvancedSearch(); resetAdvancedSearch();
break; break;
@@ -1015,7 +992,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
// Stat card click handlers for filtering // Stat card click handlers for filtering
document.querySelectorAll('.stat-card').forEach(card => { document.querySelectorAll('.stat-card').forEach(card => {
card.style.cursor = 'pointer';
card.addEventListener('click', function() { card.addEventListener('click', function() {
const classList = this.classList; const classList = this.classList;
let url = '/?'; let url = '/?';

View File

@@ -5,14 +5,14 @@
// Helper functions for timeline display // Helper functions for timeline display
function getEventIcon($actionType) { function getEventIcon($actionType) {
$icons = [ $icons = [
'create' => '', 'create' => '[ + ]',
'update' => '📝', 'update' => '[ ~ ]',
'comment' => '💬', 'comment' => '[ > ]',
'view' => '👁️', 'view' => '[ . ]',
'assign' => '👤', 'assign' => '[ @ ]',
'status_change' => '🔄' 'status_change' => '[ ! ]',
]; ];
return $icons[$actionType] ?? ''; return $icons[$actionType] ?? '[ * ]';
} }
function formatAction($event) { function formatAction($event) {
@@ -50,20 +50,21 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ticket #<?php echo $ticket['ticket_id']; ?></title> <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="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e"> <link rel="stylesheet" href="/assets/css/base.css">
<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">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script> <script nonce="<?php echo $nonce; ?>" src="/assets/js/base.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/utils.js?v=20260320"></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/markdown.js?v=20260320"></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/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; ?>"> <script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests // CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
// Timezone configuration (from server) // Timezone configuration (from server)
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>'; window.APP_TIMEZONE = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC window.APP_TIMEZONE_OFFSET = <?php echo (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0); ?>; // minutes from UTC
window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>'; window.APP_TIMEZONE_ABBREV = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE_ABBREV'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
</script> </script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
// Store ticket data in a global variable (using json_encode for XSS safety) // Store ticket data in a global variable (using json_encode for XSS safety)
@@ -80,15 +81,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<body> <body>
<header class="user-header"> <header class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a> <a href="/" class="back-link">[ &larr; DASHBOARD ]</a>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?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']): ?> <?php if ($GLOBALS['currentUser']['is_admin']): ?>
<span class="admin-badge">Admin</span> <span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?> <?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; ?> <?php endif; ?>
</div> </div>
</header> </header>
@@ -132,7 +133,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
} }
?> ?>
<div class="ticket-age <?php echo $ageClass; ?>" title="Time since last update"> <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> <span class="age-text">Last activity: <?php echo $ageStr; ?> ago</span>
</div> </div>
<div class="ticket-user-info"> <div class="ticket-user-info">
@@ -140,13 +141,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$creator = $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System'; $creator = $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System';
echo "Created by: <strong>" . htmlspecialchars($creator) . "</strong>"; echo "Created by: <strong>" . htmlspecialchars($creator) . "</strong>";
if (!empty($ticket['created_at'])) { 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'])) { if (!empty($ticket['updater_display_name']) || !empty($ticket['updater_username'])) {
$updater = $ticket['updater_display_name'] ?? $ticket['updater_username']; $updater = $ticket['updater_display_name'] ?? $ticket['updater_username'];
echo " • Last updated by: <strong>" . htmlspecialchars($updater) . "</strong>"; echo " • Last updated by: <strong>" . htmlspecialchars($updater) . "</strong>";
if (!empty($ticket['updated_at'])) { if (!empty($ticket['updated_at'])) {
echo " on " . date('M d, Y H:i', strtotime($ticket['updated_at'])); $updatedFmt = date('M d, Y H:i', strtotime($ticket['updated_at']));
echo " on <span class='ts-cell' data-ts='" . htmlspecialchars($ticket['updated_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $updatedFmt . "'>" . $updatedFmt . "</span>";
} }
} }
?> ?>
@@ -219,7 +222,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="confidential" <?php echo $currentVisibility == 'confidential' ? 'selected' : ''; ?>>Confidential</option> <option value="confidential" <?php echo $currentVisibility == 'confidential' ? 'selected' : ''; ?>>Confidential</option>
</select> </select>
</div> </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> <label class="metadata-label metadata-label-cyan">Allowed Groups:</label>
<div class="visibility-groups-edit"> <div class="visibility-groups-edit">
<?php foreach ($allAvailableGroups as $group): <?php foreach ($allAvailableGroups as $group):
@@ -242,23 +245,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
<div class="header-controls"> <div class="header-controls">
<div class="status-priority-group"> <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"> <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 $ticket['status']; ?>" selected> <option value="<?php echo htmlspecialchars($ticket['status']); ?>" selected>
<?php echo $ticket['status']; ?> (current) <?php echo htmlspecialchars($ticket['status']); ?> (current)
</option> </option>
<?php foreach ($allowedTransitions as $transition): ?> <?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-comment="<?php echo $transition['requires_comment'] ? '1' : '0'; ?>"
data-requires-admin="<?php echo $transition['requires_admin'] ? '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_comment']): ?> *<?php endif; ?>
<?php if ($transition['requires_admin']): ?> (Admin)<?php endif; ?> <?php if ($transition['requires_admin']): ?> (Admin)<?php endif; ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<button id="editButton" class="btn">Edit Ticket</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> <button id="cloneButton" class="btn btn-secondary" title="Create a copy of this ticket">CLONE</button>
</div> </div>
</div> </div>
</div> </div>
@@ -272,11 +275,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ascii-section-header">Content Sections</div> <div class="ascii-section-header">Content Sections</div>
<div class="ascii-content"> <div class="ascii-content">
<nav class="ticket-tabs" role="tablist" aria-label="Ticket content sections"> <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 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="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="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="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" id="activity-tab-btn" data-tab="activity" role="tab" aria-selected="false" aria-controls="activity-tab">ACTIVITY</button>
</nav> </nav>
</div> </div>
@@ -292,7 +295,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ascii-subsection-header">Description</div> <div class="ascii-subsection-header">Description</div>
<div class="detail-group full-width"> <div class="detail-group full-width">
<label>Description</label> <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>
</div> </div>
@@ -321,9 +324,9 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<span class="toggle-label">Preview Markdown</span> <span class="toggle-label">Preview Markdown</span>
</div> </div>
</div> </div>
<button id="addCommentBtn" class="btn">Add Comment</button> <button id="addCommentBtn" class="btn">ADD COMMENT</button>
</div> </div>
<div id="markdownPreview" class="markdown-preview" style="display: none;"></div> <div id="markdownPreview" class="markdown-preview is-hidden"></div>
</div> </div>
</div> </div>
@@ -360,18 +363,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>"; echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>";
$dateStr = date('M d, Y H:i', strtotime($comment['created_at'])); $dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited">(edited)</span>' : ''; $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 // Action buttons
echo "<div class='comment-actions'>"; echo "<div class='comment-actions'>";
// Reply button (max depth of 3) // Reply button (max depth of 3)
if ($threadDepth < 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 // Edit/Delete buttons for owner or admin
if ($canModify) { 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 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'>🗑️</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>"; echo "</div>";
@@ -420,14 +423,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<h3>Upload Files</h3> <h3>Upload Files</h3>
<div class="upload-zone" id="uploadZone"> <div class="upload-zone" id="uploadZone">
<div class="upload-zone-content"> <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>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> <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"> <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> </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-bar">
<div class="progress-fill" id="progressFill"></div> <div class="progress-fill" id="progressFill"></div>
</div> </div>
@@ -461,7 +464,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="relates_to">Relates To</option> <option value="relates_to">Relates To</option>
<option value="duplicates">Duplicates</option> <option value="duplicates">Duplicates</option>
</select> </select>
<button id="addDependencyBtn" class="btn">Add</button> <button id="addDependencyBtn" class="btn" aria-label="Add ticket dependency">ADD</button>
</div> </div>
</div> </div>
@@ -496,7 +499,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="timeline-header"> <div class="timeline-header">
<strong><?php echo htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System'); ?></strong> <strong><?php echo htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System'); ?></strong>
<span class="timeline-action"><?php echo formatAction($event); ?></span> <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> </div>
<?php if (!empty($event['details'])): ?> <?php if (!empty($event['details'])): ?>
<div class="timeline-details"> <div class="timeline-details">
@@ -556,39 +560,36 @@ $nonce = SecurityHeadersMiddleware::getNonce();
var cloneBtn = document.getElementById('cloneButton'); var cloneBtn = document.getElementById('cloneButton');
if (cloneBtn) { if (cloneBtn) {
cloneBtn.addEventListener('click', function() { 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.')) { showConfirmModal(
cloneBtn.disabled = true; 'Clone Ticket',
cloneBtn.textContent = 'Cloning...'; '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', { lt.api.post('/api/clone_ticket.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: window.ticketData.ticket_id ticket_id: window.ticketData.ticket_id
}) })
}) .then(data => {
.then(response => response.json()) if (data.success) {
.then(data => { lt.toast.success('Ticket cloned successfully!');
if (data.success) { setTimeout(function() {
toast.success('Ticket cloned successfully!'); window.location.href = '/ticket/' + data.new_ticket_id;
setTimeout(function() { }, 1000);
window.location.href = '/ticket/' + data.new_ticket_id; } else {
}, 1000); lt.toast.error('Failed to clone ticket: ' + (data.error || 'Unknown error'));
} else { cloneBtn.disabled = false;
toast.error('Failed to clone ticket: ' + (data.error || 'Unknown error')); cloneBtn.textContent = 'Clone';
}
})
.catch(function(error) {
lt.toast.error('Failed to clone ticket: ' + error.message);
cloneBtn.disabled = false; cloneBtn.disabled = false;
cloneBtn.textContent = 'Clone'; cloneBtn.textContent = 'Clone';
} });
}) }
.catch(function(error) { );
toast.error('Failed to clone ticket: ' + error.message);
cloneBtn.disabled = false;
cloneBtn.textContent = 'Clone';
});
}
}); });
} }
@@ -653,15 +654,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
}); });
} }
// Settings modal backdrop click // Settings modal backdrop click (lt-modal-overlay handles this via data-modal-close)
var settingsModal = document.getElementById('settingsModal');
if (settingsModal) {
settingsModal.addEventListener('click', function(e) {
if (e.target.classList.contains('settings-modal')) {
if (typeof closeSettingsModal === 'function') closeSettingsModal();
}
});
}
// Handle change events for data-action // Handle change events for data-action
document.addEventListener('change', function(e) { document.addEventListener('change', function(e) {
@@ -688,17 +681,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</script> </script>
<!-- Settings Modal (same as dashboard) --> <!-- Settings Modal (same as dashboard) -->
<div class="settings-modal" id="settingsModal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="ticketSettingsTitle"> <div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="ticketSettingsTitle">
<div class="settings-content"> <div class="lt-modal">
<span class="bottom-left-corner">╚</span> <div class="lt-modal-header">
<span class="bottom-right-corner">╝</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 class="settings-header">
<h3 id="ticketSettingsTitle">⚙ System Preferences</h3>
<button class="close-settings" id="closeSettingsBtn" aria-label="Close settings">✗</button>
</div> </div>
<div class="settings-body"> <div class="lt-modal-body">
<!-- Display Preferences --> <!-- Display Preferences -->
<div class="settings-section"> <div class="settings-section">
<h4>╔══ Display Preferences ══╗</h4> <h4>╔══ Display Preferences ══╗</h4>
@@ -816,13 +806,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<div class="settings-footer"> <div class="lt-modal-footer">
<button class="btn btn-primary" id="saveSettingsBtn">Save Preferences</button> <button class="lt-btn lt-btn-primary" id="saveSettingsBtn">SAVE PREFERENCES</button>
<button class="btn btn-secondary" id="cancelSettingsBtn">Cancel</button> <button class="lt-btn lt-btn-ghost" id="cancelSettingsBtn">CANCEL</button>
</div> </div>
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
</body> </body>
</html> </html>

View File

@@ -12,9 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Keys - Admin</title> <title>API Keys - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="/assets/css/base.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">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script> <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; ?>"> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script> </script>
@@ -22,35 +24,34 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<body> <body>
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a> <a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: API Keys</span> <span class="admin-page-title">Admin: API Keys</span>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?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>
<span class="admin-badge">Admin</span> <span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
</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-left-corner">╚</span>
<span class="bottom-right-corner">╝</span> <span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">API Key Management</div> <div class="ascii-section-header">API Key Management</div>
<div class="ascii-content"> <div class="ascii-content">
<!-- Generate New Key Form --> <!-- Generate New Key Form -->
<div class="ascii-frame-inner" style="margin-bottom: 1.5rem;"> <div class="ascii-frame-inner">
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">Generate New API Key</h3> <h3 class="admin-section-title">Generate New API Key</h3>
<form id="generateKeyForm" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end;"> <form id="generateKeyForm" class="admin-form-row">
<div style="flex: 1; min-width: 200px;"> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber); margin-bottom: 0.25rem;">Key Name *</label> <label class="admin-label" for="keyName">Key Name *</label>
<input type="text" id="keyName" required placeholder="e.g., CI/CD Pipeline" <input type="text" id="keyName" required placeholder="e.g., CI/CD Pipeline" class="admin-input">
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> </div>
<div style="min-width: 150px;"> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber); margin-bottom: 0.25rem;">Expires In</label> <label class="admin-label" for="expiresIn">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);"> <select id="expiresIn" class="admin-input">
<option value="">Never</option> <option value="">Never</option>
<option value="30">30 days</option> <option value="30">30 days</option>
<option value="90">90 days</option> <option value="90">90 days</option>
@@ -59,28 +60,28 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</select> </select>
</div> </div>
<div> <div>
<button type="submit" class="btn">Generate Key</button> <button type="submit" class="btn">GENERATE KEY</button>
</div> </div>
</form> </form>
</div> </div>
<!-- New Key Display (hidden by default) --> <!-- 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);"> <div id="newKeyDisplay" class="ascii-frame-inner key-generated-alert is-hidden">
<h3 style="color: var(--terminal-amber); margin-bottom: 0.5rem;">New API Key Generated</h3> <h3 class="admin-section-title">New API Key Generated</h3>
<p style="color: var(--priority-1); margin-bottom: 1rem; font-size: 0.9rem;"> <p class="text-danger text-sm mb-1">
Copy this key now. You won't be able to see it again! Copy this key now. You won't be able to see it again!
</p> </p>
<div style="display: flex; gap: 0.5rem; align-items: center;"> <div class="admin-form-row">
<input type="text" id="newKeyValue" readonly <input type="text" id="newKeyValue" readonly class="admin-input">
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>
<button data-action="copy-api-key" class="btn" title="Copy to clipboard">Copy</button>
</div> </div>
</div> </div>
<!-- Existing Keys Table --> <!-- Existing Keys Table -->
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">Existing API Keys</h3> <h3 class="admin-section-title">Existing API Keys</h3>
<table style="width: 100%; font-size: 0.9rem;"> <div class="table-wrapper">
<table>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
@@ -96,50 +97,45 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody> <tbody>
<?php if (empty($apiKeys)): ?> <?php if (empty($apiKeys)): ?>
<tr> <tr>
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);"> <td colspan="8" class="empty-state">No API keys found. Generate one above.</td>
No API keys found. Generate one above.
</td>
</tr> </tr>
<?php else: ?> <?php else: ?>
<?php foreach ($apiKeys as $key): ?> <?php foreach ($apiKeys as $key): ?>
<tr id="key-row-<?php echo $key['api_key_id']; ?>"> <tr id="key-row-<?php echo $key['api_key_id']; ?>">
<td><?php echo htmlspecialchars($key['key_name']); ?></td> <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> <code><?php echo htmlspecialchars($key['key_prefix']); ?>...</code>
</td> </td>
<td><?php echo htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown'); ?></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 class="nowrap"><?php echo date('Y-m-d H:i', strtotime($key['created_at'])); ?></td>
<td style="white-space: nowrap;"> <td class="nowrap">
<?php if ($key['expires_at']): ?> <?php if ($key['expires_at']): ?>
<?php <?php $expired = strtotime($key['expires_at']) < time(); ?>
$expired = strtotime($key['expires_at']) < time(); <span class="<?php echo $expired ? 'text-danger' : ''; ?>">
$color = $expired ? 'var(--priority-1)' : 'var(--terminal-green)';
?>
<span style="color: <?php echo $color; ?>;">
<?php echo date('Y-m-d', strtotime($key['expires_at'])); ?> <?php echo date('Y-m-d', strtotime($key['expires_at'])); ?>
<?php if ($expired): ?> (Expired)<?php endif; ?> <?php if ($expired): ?> (Expired)<?php endif; ?>
</span> </span>
<?php else: ?> <?php else: ?>
<span style="color: var(--terminal-cyan);">Never</span> <span class="text-cyan">Never</span>
<?php endif; ?> <?php endif; ?>
</td> </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'; ?> <?php echo $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never'; ?>
</td> </td>
<td> <td>
<?php if ($key['is_active']): ?> <?php if ($key['is_active']): ?>
<span style="color: var(--status-open);">Active</span> <span class="text-open">Active</span>
<?php else: ?> <?php else: ?>
<span style="color: var(--status-closed);">Revoked</span> <span class="text-closed">Revoked</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<?php if ($key['is_active']): ?> <?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;"> <button data-action="revoke-key" data-id="<?php echo $key['api_key_id']; ?>" class="btn btn-secondary btn-small">
Revoke REVOKE
</button> </button>
<?php else: ?> <?php else: ?>
<span style="color: var(--text-muted);">-</span> <span class="text-muted">-</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
@@ -147,14 +143,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
<!-- API Usage Info --> <!-- API Usage Info -->
<div class="ascii-frame-inner" style="margin-top: 1.5rem;"> <div class="ascii-frame-inner">
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">API Usage</h3> <h3 class="admin-section-title">API Usage</h3>
<p style="margin-bottom: 0.5rem;">Include the API key in your requests using the Authorization header:</p> <p>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> <pre class="admin-code-block"><code>Authorization: Bearer YOUR_API_KEY</code></pre>
<p style="margin-top: 1rem; font-size: 0.9rem; color: var(--text-muted);"> <p class="text-muted text-sm">
API keys provide programmatic access to create and manage tickets. Keep your keys secure and rotate them regularly. API keys provide programmatic access to create and manage tickets. Keep your keys secure and rotate them regularly.
</p> </p>
</div> </div>
@@ -185,40 +182,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
const expiresIn = document.getElementById('expiresIn').value; const expiresIn = document.getElementById('expiresIn').value;
if (!keyName) { if (!keyName) {
showToast('Please enter a key name', 'error'); lt.toast.error('Please enter a key name');
return; return;
} }
try { try {
const response = await fetch('/api/generate_api_key.php', { const data = await lt.api.post('/api/generate_api_key.php', {
method: 'POST', key_name: keyName,
headers: { expires_in_days: expiresIn || null
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
key_name: keyName,
expires_in_days: expiresIn || null
})
}); });
const data = await response.json();
if (data.success) { if (data.success) {
// Show the new key // Show the new key
document.getElementById('newKeyValue').value = data.api_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 = ''; 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 // Reload page after 5 seconds to show new key in table
setTimeout(() => location.reload(), 5000); setTimeout(() => location.reload(), 5000);
} else { } else {
showToast(data.error || 'Failed to generate API key', 'error'); lt.toast.error(data.error || 'Failed to generate API key');
} }
} catch (error) { } catch (error) {
showToast('Error generating API key: ' + error.message, 'error'); lt.toast.error('Error generating API key: ' + error.message);
} }
}); });
@@ -226,35 +214,24 @@ $nonce = SecurityHeadersMiddleware::getNonce();
const keyInput = document.getElementById('newKeyValue'); const keyInput = document.getElementById('newKeyValue');
keyInput.select(); keyInput.select();
document.execCommand('copy'); document.execCommand('copy');
showToast('API key copied to clipboard', 'success'); lt.toast.success('API key copied to clipboard');
} }
async function revokeKey(keyId) { function revokeKey(keyId) {
if (!confirm('Are you sure you want to revoke this API key? This action cannot be undone.')) { showConfirmModal('Revoke API Key', 'Are you sure you want to revoke this API key? This action cannot be undone.', 'error', function() {
return; lt.api.post('/api/revoke_api_key.php', { key_id: keyId })
} .then(data => {
if (data.success) {
try { lt.toast.success('API key revoked successfully');
const response = await fetch('/api/revoke_api_key.php', { location.reload();
method: 'POST', } else {
headers: { lt.toast.error(data.error || 'Failed to revoke API key');
'Content-Type': 'application/json', }
'X-CSRF-Token': window.CSRF_TOKEN })
}, .catch(error => {
body: JSON.stringify({ key_id: keyId }) 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> </script>
</body> </body>

View File

@@ -1,6 +1,9 @@
<?php <?php
// Admin view for browsing audit logs // Admin view for browsing audit logs
// Receives $auditLogs, $totalPages, $page, $filters from controller // Receives $auditLogs, $totalPages, $page, $filters from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -9,24 +12,29 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audit Log - Admin</title> <title>Audit Log - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="/assets/css/base.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; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head> </head>
<body> <body>
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a> <a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Audit Log</span> <span class="admin-page-title">Admin: Audit Log</span>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?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>
<span class="admin-badge">Admin</span> <span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
</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-left-corner">╚</span>
<span class="bottom-right-corner">╝</span> <span class="bottom-right-corner">╝</span>
@@ -34,10 +42,10 @@
<div class="ascii-content"> <div class="ascii-content">
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<!-- Filters --> <!-- Filters -->
<form method="GET" style="display: flex; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap;"> <form method="GET" class="admin-form-row">
<div> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Action Type</label> <label class="admin-label" for="action_type">Action Type</label>
<select name="action_type" class="setting-select"> <select name="action_type" id="action_type" class="admin-input">
<option value="">All Actions</option> <option value="">All Actions</option>
<option value="create" <?php echo ($filters['action_type'] ?? '') === 'create' ? 'selected' : ''; ?>>Create</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> <option value="update" <?php echo ($filters['action_type'] ?? '') === 'update' ? 'selected' : ''; ?>>Update</option>
@@ -49,9 +57,9 @@
<option value="security" <?php echo ($filters['action_type'] ?? '') === 'security' ? 'selected' : ''; ?>>Security</option> <option value="security" <?php echo ($filters['action_type'] ?? '') === 'security' ? 'selected' : ''; ?>>Security</option>
</select> </select>
</div> </div>
<div> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">User</label> <label class="admin-label" for="user_id">User</label>
<select name="user_id" class="setting-select"> <select name="user_id" id="user_id" class="admin-input">
<option value="">All Users</option> <option value="">All Users</option>
<?php if (isset($users)): foreach ($users as $user): ?> <?php if (isset($users)): foreach ($users as $user): ?>
<option value="<?php echo $user['user_id']; ?>" <?php echo ($filters['user_id'] ?? '') == $user['user_id'] ? 'selected' : ''; ?>> <option value="<?php echo $user['user_id']; ?>" <?php echo ($filters['user_id'] ?? '') == $user['user_id'] ? 'selected' : ''; ?>>
@@ -60,22 +68,23 @@
<?php endforeach; endif; ?> <?php endforeach; endif; ?>
</select> </select>
</div> </div>
<div> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date From</label> <label class="admin-label" for="date_from">Date From</label>
<input type="date" name="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="setting-select"> <input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="admin-input">
</div> </div>
<div> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date To</label> <label class="admin-label" for="date_to">Date To</label>
<input type="date" name="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="setting-select"> <input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="admin-input">
</div> </div>
<div style="display: flex; align-items: flex-end;"> <div class="admin-form-actions">
<button type="submit" class="btn">Filter</button> <button type="submit" class="btn">FILTER</button>
<a href="?" class="btn btn-secondary" style="margin-left: 0.5rem;">Reset</a> <a href="?" class="btn btn-secondary">RESET</a>
</div> </div>
</form> </form>
<!-- Log Table --> <!-- Log Table -->
<table style="width: 100%; font-size: 0.9rem;"> <div class="table-wrapper">
<table>
<thead> <thead>
<tr> <tr>
<th>Timestamp</th> <th>Timestamp</th>
@@ -90,34 +99,32 @@
<tbody> <tbody>
<?php if (empty($auditLogs)): ?> <?php if (empty($auditLogs)): ?>
<tr> <tr>
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);"> <td colspan="7" class="empty-state">No audit log entries found.</td>
No audit log entries found.
</td>
</tr> </tr>
<?php else: ?> <?php else: ?>
<?php foreach ($auditLogs as $log): ?> <?php foreach ($auditLogs as $log): ?>
<tr> <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><?php echo htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System'); ?></td>
<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>
<td><?php echo htmlspecialchars($log['entity_type'] ?? '-'); ?></td> <td><?php echo htmlspecialchars($log['entity_type'] ?? '-'); ?></td>
<td> <td>
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?> <?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']); ?> <?php echo htmlspecialchars($log['entity_id']); ?>
</a> </a>
<?php else: ?> <?php else: ?>
<?php echo htmlspecialchars($log['entity_id'] ?? '-'); ?> <?php echo htmlspecialchars($log['entity_id'] ?? '-'); ?>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;"> <td class="td-truncate">
<?php <?php
if ($log['details']) { if ($log['details']) {
$details = is_string($log['details']) ? json_decode($log['details'], true) : $log['details']; $details = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
if (is_array($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 { } else {
echo htmlspecialchars($log['details']); echo htmlspecialchars($log['details']);
} }
@@ -126,22 +133,23 @@
} }
?> ?>
</td> </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> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div>
<!-- Pagination --> <!-- Pagination -->
<?php if ($totalPages > 1): ?> <?php if ($totalPages > 1): ?>
<div class="pagination" style="margin-top: 1rem; text-align: center;"> <div class="pagination">
<?php <?php
$params = $_GET; $params = $_GET;
for ($i = 1; $i <= min($totalPages, 10); $i++) { for ($i = 1; $i <= min($totalPages, 10); $i++) {
$params['page'] = $i; $params['page'] = $i;
$activeClass = ($i == $page) ? 'active' : ''; $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> "; echo "<a href='$url' class='btn btn-small $activeClass'>$i</a> ";
} }
if ($totalPages > 10) { if ($totalPages > 10) {
@@ -153,5 +161,6 @@
</div> </div>
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
</body> </body>
</html> </html>

View File

@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Fields - Admin</title> <title>Custom Fields - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="/assets/css/base.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; ?>"> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script> </script>
@@ -21,30 +24,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<body> <body>
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a> <a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Custom Fields</span> <span class="admin-page-title">Admin: Custom Fields</span>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?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>
<span class="admin-badge">Admin</span> <span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
</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-left-corner">╚</span>
<span class="bottom-right-corner">╝</span> <span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Custom Fields Management</div> <div class="ascii-section-header">Custom Fields Management</div>
<div class="ascii-content"> <div class="ascii-content">
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> <div class="admin-header-row">
<h2 style="margin: 0;">Custom Field Definitions</h2> <h2>Custom Field Definitions</h2>
<button data-action="show-create-modal" class="btn">+ New Field</button> <button data-action="show-create-modal" class="btn">+ NEW FIELD</button>
</div> </div>
<table style="width: 100%;"> <div class="table-wrapper">
<table>
<thead> <thead>
<tr> <tr>
<th>Order</th> <th>Order</th>
@@ -60,9 +64,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody> <tbody>
<?php if (empty($customFields)): ?> <?php if (empty($customFields)): ?>
<tr> <tr>
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);"> <td colspan="8" class="empty-state">No custom fields defined.</td>
No custom fields defined.
</td>
</tr> </tr>
<?php else: ?> <?php else: ?>
<?php foreach ($customFields as $field): ?> <?php foreach ($customFields as $field): ?>
@@ -74,33 +76,34 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<td><?php echo htmlspecialchars($field['category'] ?? 'All'); ?></td> <td><?php echo htmlspecialchars($field['category'] ?? 'All'); ?></td>
<td><?php echo $field['is_required'] ? 'Yes' : 'No'; ?></td> <td><?php echo $field['is_required'] ? 'Yes' : 'No'; ?></td>
<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'; ?> <?php echo $field['is_active'] ? 'Active' : 'Inactive'; ?>
</span> </span>
</td> </td>
<td> <td>
<button data-action="edit-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small">Edit</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> <button data-action="delete-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Create/Edit Modal --> <!-- Create/Edit Modal -->
<div class="settings-modal" id="fieldModal" style="display: none;" data-action="close-modal-backdrop"> <div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="settings-content" style="max-width: 500px;"> <div class="lt-modal lt-modal-sm">
<div class="settings-header"> <div class="lt-modal-header">
<h3 id="modalTitle">Create Custom Field</h3> <span class="lt-modal-title" id="modalTitle">Create Custom Field</span>
<button class="close-settings" data-action="close-modal">×</button> <button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div> </div>
<form id="fieldForm"> <form id="fieldForm">
<input type="hidden" id="field_id" name="field_id"> <input type="hidden" id="field_id" name="field_id">
<div class="settings-body"> <div class="lt-modal-body">
<div class="setting-row"> <div class="setting-row">
<label for="field_name">Field Name * (internal)</label> <label for="field_name">Field Name * (internal)</label>
<input type="text" id="field_name" name="field_name" required pattern="[a-z_]+" placeholder="e.g., server_name"> <input type="text" id="field_name" name="field_name" required pattern="[a-z_]+" placeholder="e.g., server_name">
@@ -120,7 +123,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="number">Number</option> <option value="number">Number</option>
</select> </select>
</div> </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> <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> <textarea id="field_options" name="field_options" rows="4" placeholder="Option 1&#10;Option 2&#10;Option 3"></textarea>
</div> </div>
@@ -146,15 +149,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label> <label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
</div> </div>
</div> </div>
<div class="settings-footer"> <div class="lt-modal-footer">
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button> <button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
function showCreateModal() { function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Custom Field'; document.getElementById('modalTitle').textContent = 'Create Custom Field';
@@ -162,11 +164,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
document.getElementById('field_id').value = ''; document.getElementById('field_id').value = '';
document.getElementById('is_active').checked = true; document.getElementById('is_active').checked = true;
toggleOptionsField(); toggleOptionsField();
document.getElementById('fieldModal').style.display = 'flex'; lt.modal.open('fieldModal');
} }
function closeModal() { function closeModal() {
document.getElementById('fieldModal').style.display = 'none'; lt.modal.close('fieldModal');
} }
// Event delegation for data-action handlers // Event delegation for data-action handlers
@@ -179,12 +181,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
case 'show-create-modal': case 'show-create-modal':
showCreateModal(); showCreateModal();
break; break;
case 'close-modal':
closeModal();
break;
case 'close-modal-backdrop':
if (event.target === target) closeModal();
break;
case 'edit-field': case 'edit-field':
editField(target.dataset.id); editField(target.dataset.id);
break; break;
@@ -208,16 +204,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
saveField(e); saveField(e);
}); });
// Close modal on ESC key if (window.lt) lt.keys.initDefaults();
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
function toggleOptionsField() { function toggleOptionsField() {
const type = document.getElementById('field_type').value; 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) { function saveField(e) {
@@ -239,30 +230,19 @@ $nonce = SecurityHeadersMiddleware::getNonce();
data.field_options = { options: options }; 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 : ''); const url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : '');
const apiCall = data.field_id ? lt.api.put(url, data) : lt.api.post(url, data);
fetch(url, { apiCall.then(result => {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
if (result.success) { if (result.success) {
window.location.reload(); window.location.reload();
} else { } else {
toast.error(result.error || 'Failed to save'); lt.toast.error(result.error || 'Failed to save');
} }
}); }).catch(err => lt.toast.error('Failed to save'));
} }
function editField(id) { function editField(id) {
fetch('/api/custom_fields.php?id=' + id) lt.api.get('/api/custom_fields.php?id=' + id)
.then(r => r.json())
.then(data => { .then(data => {
if (data.success && data.field) { if (data.success && data.field) {
const f = data.field; const f = data.field;
@@ -279,20 +259,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
document.getElementById('field_options').value = f.field_options.options.join('\n'); document.getElementById('field_options').value = f.field_options.options.join('\n');
} }
document.getElementById('modalTitle').textContent = 'Edit Custom Field'; document.getElementById('modalTitle').textContent = 'Edit Custom Field';
document.getElementById('fieldModal').style.display = 'flex'; lt.modal.open('fieldModal');
} }
}); });
} }
function deleteField(id) { function deleteField(id) {
if (!confirm('Delete this custom field? All values will be lost.')) return; showConfirmModal('Delete Custom Field', 'Delete this custom field? All values will be lost.', 'error', function() {
fetch('/api/custom_fields.php?id=' + id, { lt.api.delete('/api/custom_fields.php?id=' + id)
method: 'DELETE', .then(data => {
headers: { 'X-CSRF-Token': window.CSRF_TOKEN } if (data.success) window.location.reload();
}) else lt.toast.error(data.error || 'Failed to delete');
.then(r => r.json()) }).catch(err => lt.toast.error('Failed to delete'));
.then(data => {
if (data.success) window.location.reload();
}); });
} }
</script> </script>

View File

@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recurring Tickets - Admin</title> <title>Recurring Tickets - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="/assets/css/base.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; ?>"> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script> </script>
@@ -21,30 +24,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<body> <body>
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a> <a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Recurring Tickets</span> <span class="admin-page-title">Admin: Recurring Tickets</span>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?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>
<span class="admin-badge">Admin</span> <span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
</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-left-corner">╚</span>
<span class="bottom-right-corner">╝</span> <span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Recurring Tickets Management</div> <div class="ascii-section-header">Recurring Tickets Management</div>
<div class="ascii-content"> <div class="ascii-content">
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> <div class="admin-header-row">
<h2 style="margin: 0;">Scheduled Tickets</h2> <h2>Scheduled Tickets</h2>
<button data-action="show-create-modal" class="btn">+ New Recurring Ticket</button> <button data-action="show-create-modal" class="btn">+ NEW RECURRING TICKET</button>
</div> </div>
<table style="width: 100%;"> <div class="table-wrapper">
<table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@@ -60,9 +64,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody> <tbody>
<?php if (empty($recurringTickets)): ?> <?php if (empty($recurringTickets)): ?>
<tr> <tr>
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);"> <td colspan="8" class="empty-state">No recurring tickets configured.</td>
No recurring tickets configured.
</td>
</tr> </tr>
<?php else: ?> <?php else: ?>
<?php foreach ($recurringTickets as $rt): ?> <?php foreach ($recurringTickets as $rt): ?>
@@ -79,50 +81,51 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$schedule .= ' (Day ' . $rt['schedule_day'] . ')'; $schedule .= ' (Day ' . $rt['schedule_day'] . ')';
} }
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5); $schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
echo $schedule; echo htmlspecialchars($schedule);
?> ?>
</td> </td>
<td><?php echo htmlspecialchars($rt['category']); ?></td> <td><?php echo htmlspecialchars($rt['category']); ?></td>
<td><?php echo htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned'); ?></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> <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'; ?> <?php echo $rt['is_active'] ? 'Active' : 'Inactive'; ?>
</span> </span>
</td> </td>
<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"> <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>
<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> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Create/Edit Modal --> <!-- Create/Edit Modal -->
<div class="settings-modal" id="recurringModal" style="display: none;" data-action="close-modal-backdrop"> <div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="settings-content" style="max-width: 800px; width: 90%;"> <div class="lt-modal lt-modal-lg">
<div class="settings-header"> <div class="lt-modal-header">
<h3 id="modalTitle">Create Recurring Ticket</h3> <span class="lt-modal-title" id="modalTitle">Create Recurring Ticket</span>
<button class="close-settings" data-action="close-modal">×</button> <button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div> </div>
<form id="recurringForm"> <form id="recurringForm">
<input type="hidden" id="recurring_id" name="recurring_id"> <input type="hidden" id="recurring_id" name="recurring_id">
<div class="settings-body"> <div class="lt-modal-body">
<div class="setting-row"> <div class="setting-row">
<label for="title_template">Title Template *</label> <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>
<div class="setting-row"> <div class="setting-row">
<label for="description_template">Description Template</label> <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>
<div class="setting-row"> <div class="setting-row">
<label for="schedule_type">Schedule Type *</label> <label for="schedule_type">Schedule Type *</label>
@@ -132,7 +135,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="monthly">Monthly</option> <option value="monthly">Monthly</option>
</select> </select>
</div> </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> <label for="schedule_day">Schedule Day</label>
<select id="schedule_day" name="schedule_day"></select> <select id="schedule_day" name="schedule_day"></select>
</div> </div>
@@ -140,7 +143,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label for="schedule_time">Schedule Time *</label> <label for="schedule_time">Schedule Time *</label>
<input type="time" id="schedule_time" name="schedule_time" value="09:00" required> <input type="time" id="schedule_time" name="schedule_time" value="09:00" required>
</div> </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"> <div class="setting-row setting-row-compact">
<label for="category">Category</label> <label for="category">Category</label>
<select id="category" name="category"> <select id="category" name="category">
@@ -181,26 +184,25 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
</div> </div>
<div class="settings-footer"> <div class="lt-modal-footer">
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button> <button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
function showCreateModal() { function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket'; document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
document.getElementById('recurringForm').reset(); document.getElementById('recurringForm').reset();
document.getElementById('recurring_id').value = ''; document.getElementById('recurring_id').value = '';
updateScheduleOptions(); updateScheduleOptions();
document.getElementById('recurringModal').style.display = 'flex'; lt.modal.open('recurringModal');
} }
function closeModal() { function closeModal() {
document.getElementById('recurringModal').style.display = 'none'; lt.modal.close('recurringModal');
} }
// Event delegation for data-action handlers // Event delegation for data-action handlers
@@ -213,12 +215,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
case 'show-create-modal': case 'show-create-modal':
showCreateModal(); showCreateModal();
break; break;
case 'close-modal':
closeModal();
break;
case 'close-modal-backdrop':
if (event.target === target) closeModal();
break;
case 'edit-recurring': case 'edit-recurring':
editRecurring(target.dataset.id); editRecurring(target.dataset.id);
break; break;
@@ -245,12 +241,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
saveRecurring(e); saveRecurring(e);
}); });
// Close modal on ESC key if (window.lt) lt.keys.initDefaults();
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
function updateScheduleOptions() { function updateScheduleOptions() {
const type = document.getElementById('schedule_type').value; const type = document.getElementById('schedule_type').value;
@@ -260,15 +251,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
daySelect.innerHTML = ''; daySelect.innerHTML = '';
if (type === 'daily') { if (type === 'daily') {
dayRow.style.display = 'none'; dayRow.classList.add('is-hidden');
} else if (type === 'weekly') { } else if (type === 'weekly') {
dayRow.style.display = 'flex'; dayRow.classList.remove('is-hidden');
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
days.forEach((day, i) => { days.forEach((day, i) => {
daySelect.innerHTML += `<option value="${i + 1}">${day}</option>`; daySelect.innerHTML += `<option value="${i + 1}">${day}</option>`;
}); });
} else if (type === 'monthly') { } else if (type === 'monthly') {
dayRow.style.display = 'flex'; dayRow.classList.remove('is-hidden');
for (let i = 1; i <= 28; i++) { for (let i = 1; i <= 28; i++) {
daySelect.innerHTML += `<option value="${i}">Day ${i}</option>`; daySelect.innerHTML += `<option value="${i}">Day ${i}</option>`;
} }
@@ -280,53 +271,37 @@ $nonce = SecurityHeadersMiddleware::getNonce();
const form = new FormData(document.getElementById('recurringForm')); const form = new FormData(document.getElementById('recurringForm'));
const data = Object.fromEntries(form); 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 : ''); const url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : '');
const apiCall = data.recurring_id ? lt.api.put(url, data) : lt.api.post(url, data);
fetch(url, { apiCall.then(result => {
method: method, if (result.success) {
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.reload(); window.location.reload();
} else { } else {
toast.error(data.error || 'Failed to save'); lt.toast.error(result.error || 'Failed to save');
} }
}); }).catch(err => lt.toast.error('Failed to save'));
} }
function toggleRecurring(id) { function toggleRecurring(id) {
fetch('/api/manage_recurring.php?action=toggle&id=' + id, { lt.api.post('/api/manage_recurring.php?action=toggle&id=' + id)
method: 'POST',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
.then(data => { .then(data => {
if (data.success) window.location.reload(); 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) { function deleteRecurring(id) {
if (!confirm('Delete this recurring ticket schedule?')) return; showConfirmModal('Delete Schedule', 'Delete this recurring ticket schedule?', 'error', function() {
fetch('/api/manage_recurring.php?id=' + id, { lt.api.delete('/api/manage_recurring.php?id=' + id)
method: 'DELETE', .then(data => {
headers: { 'X-CSRF-Token': window.CSRF_TOKEN } if (data.success) window.location.reload();
}) else lt.toast.error(data.error || 'Failed to delete');
.then(r => r.json()) }).catch(err => lt.toast.error('Failed to delete'));
.then(data => {
if (data.success) window.location.reload();
}); });
} }
function editRecurring(id) { function editRecurring(id) {
fetch('/api/manage_recurring.php?id=' + id) lt.api.get('/api/manage_recurring.php?id=' + id)
.then(r => r.json())
.then(data => { .then(data => {
if (data.success && data.recurring) { if (data.success && data.recurring) {
const rt = data.recurring; const rt = data.recurring;
@@ -342,15 +317,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
document.getElementById('priority').value = rt.priority || 4; document.getElementById('priority').value = rt.priority || 4;
document.getElementById('assigned_to').value = rt.assigned_to || ''; document.getElementById('assigned_to').value = rt.assigned_to || '';
document.getElementById('modalTitle').textContent = 'Edit Recurring Ticket'; document.getElementById('modalTitle').textContent = 'Edit Recurring Ticket';
document.getElementById('recurringModal').style.display = 'flex'; lt.modal.open('recurringModal');
} }
}); });
} }
// Load users for assignee dropdown // Load users for assignee dropdown
function loadUsers() { function loadUsers() {
fetch('/api/get_users.php') lt.api.get('/api/get_users.php')
.then(r => r.json())
.then(data => { .then(data => {
if (data.success && data.users) { if (data.success && data.users) {
const select = document.getElementById('assigned_to'); const select = document.getElementById('assigned_to');

View File

@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Template Management - Admin</title> <title>Template Management - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="/assets/css/base.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; ?>"> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script> </script>
@@ -21,34 +24,35 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<body> <body>
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a> <a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Templates</span> <span class="admin-page-title">Admin: Templates</span>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?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>
<span class="admin-badge">Admin</span> <span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
</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-left-corner">╚</span>
<span class="bottom-right-corner">╝</span> <span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Ticket Template Management</div> <div class="ascii-section-header">Ticket Template Management</div>
<div class="ascii-content"> <div class="ascii-content">
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> <div class="admin-header-row">
<h2 style="margin: 0;">Ticket Templates</h2> <h2>Ticket Templates</h2>
<button data-action="show-create-modal" class="btn">+ New Template</button> <button data-action="show-create-modal" class="btn">+ NEW TEMPLATE</button>
</div> </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. Templates pre-fill ticket creation forms with standard content for common ticket types.
</p> </p>
<table style="width: 100%;"> <div class="table-wrapper">
<table>
<thead> <thead>
<tr> <tr>
<th>Template Name</th> <th>Template Name</th>
@@ -62,9 +66,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody> <tbody>
<?php if (empty($templates)): ?> <?php if (empty($templates)): ?>
<tr> <tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);"> <td colspan="6" class="empty-state">No templates defined. Create templates to speed up ticket creation.</td>
No templates defined. Create templates to speed up ticket creation.
</td>
</tr> </tr>
<?php else: ?> <?php else: ?>
<?php foreach ($templates as $tpl): ?> <?php foreach ($templates as $tpl): ?>
@@ -74,46 +76,47 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<td><?php echo htmlspecialchars($tpl['type'] ?? 'Any'); ?></td> <td><?php echo htmlspecialchars($tpl['type'] ?? 'Any'); ?></td>
<td>P<?php echo $tpl['default_priority'] ?? '4'; ?></td> <td>P<?php echo $tpl['default_priority'] ?? '4'; ?></td>
<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'; ?> <?php echo ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive'; ?>
</span> </span>
</td> </td>
<td> <td>
<button data-action="edit-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small">Edit</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> <button data-action="delete-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Create/Edit Modal --> <!-- Create/Edit Modal -->
<div class="settings-modal" id="templateModal" style="display: none;" data-action="close-modal-backdrop"> <div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="settings-content" style="max-width: 800px; width: 90%;"> <div class="lt-modal lt-modal-lg">
<div class="settings-header"> <div class="lt-modal-header">
<h3 id="modalTitle">Create Template</h3> <span class="lt-modal-title" id="modalTitle">Create Template</span>
<button class="close-settings" data-action="close-modal">×</button> <button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div> </div>
<form id="templateForm"> <form id="templateForm">
<input type="hidden" id="template_id" name="template_id"> <input type="hidden" id="template_id" name="template_id">
<div class="settings-body"> <div class="lt-modal-body">
<div class="setting-row"> <div class="setting-row">
<label for="template_name">Template Name *</label> <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>
<div class="setting-row"> <div class="setting-row">
<label for="title_template">Title Template</label> <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>
<div class="setting-row"> <div class="setting-row">
<label for="description_template">Description Template</label> <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>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;"> <div class="setting-grid-3">
<div class="setting-row setting-row-compact"> <div class="setting-row setting-row-compact">
<label for="category">Category</label> <label for="category">Category</label>
<select id="category" name="category"> <select id="category" name="category">
@@ -152,15 +155,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label> <label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
</div> </div>
</div> </div>
<div class="settings-footer"> <div class="lt-modal-footer">
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button> <button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
const templates = <?php echo json_encode($templates ?? []); ?>; const templates = <?php echo json_encode($templates ?? []); ?>;
@@ -169,11 +171,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
document.getElementById('templateForm').reset(); document.getElementById('templateForm').reset();
document.getElementById('template_id').value = ''; document.getElementById('template_id').value = '';
document.getElementById('is_active').checked = true; document.getElementById('is_active').checked = true;
document.getElementById('templateModal').style.display = 'flex'; lt.modal.open('templateModal');
} }
function closeModal() { function closeModal() {
document.getElementById('templateModal').style.display = 'none'; lt.modal.close('templateModal');
} }
// Event delegation for data-action handlers // Event delegation for data-action handlers
@@ -186,12 +188,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
case 'show-create-modal': case 'show-create-modal':
showCreateModal(); showCreateModal();
break; break;
case 'close-modal':
closeModal();
break;
case 'close-modal-backdrop':
if (event.target === target) closeModal();
break;
case 'edit-template': case 'edit-template':
editTemplate(target.dataset.id); editTemplate(target.dataset.id);
break; break;
@@ -206,12 +202,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
saveTemplate(e); saveTemplate(e);
}); });
// Close modal on ESC key if (window.lt) lt.keys.initDefaults();
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
function saveTemplate(e) { function saveTemplate(e) {
e.preventDefault(); e.preventDefault();
@@ -226,25 +217,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
is_active: document.getElementById('is_active').checked ? 1 : 0 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 : ''); const url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : '');
const apiCall = data.template_id ? lt.api.put(url, data) : lt.api.post(url, data);
fetch(url, { apiCall.then(result => {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
if (result.success) { if (result.success) {
window.location.reload(); window.location.reload();
} else { } else {
toast.error(result.error || 'Failed to save'); lt.toast.error(result.error || 'Failed to save');
} }
}); }).catch(err => lt.toast.error('Failed to save'));
} }
function editTemplate(id) { function editTemplate(id) {
@@ -260,18 +241,16 @@ $nonce = SecurityHeadersMiddleware::getNonce();
document.getElementById('priority').value = tpl.default_priority || 4; document.getElementById('priority').value = tpl.default_priority || 4;
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1; document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
document.getElementById('modalTitle').textContent = 'Edit Template'; document.getElementById('modalTitle').textContent = 'Edit Template';
document.getElementById('templateModal').style.display = 'flex'; lt.modal.open('templateModal');
} }
function deleteTemplate(id) { function deleteTemplate(id) {
if (!confirm('Delete this template?')) return; showConfirmModal('Delete Template', 'Delete this template?', 'error', function() {
fetch('/api/manage_templates.php?id=' + id, { lt.api.delete('/api/manage_templates.php?id=' + id)
method: 'DELETE', .then(data => {
headers: { 'X-CSRF-Token': window.CSRF_TOKEN } if (data.success) window.location.reload();
}) else lt.toast.error(data.error || 'Failed to delete');
.then(r => r.json()) }).catch(err => lt.toast.error('Failed to delete'));
.then(data => {
if (data.success) window.location.reload();
}); });
} }
</script> </script>

View File

@@ -1,6 +1,9 @@
<?php <?php
// Admin view for user activity reports // Admin view for user activity reports
// Receives $userStats, $dateRange from controller // Receives $userStats, $dateRange from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -9,24 +12,29 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Activity - Admin</title> <title>User Activity - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="/assets/css/base.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; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head> </head>
<body> <body>
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a> <a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: User Activity</span> <span class="admin-page-title">Admin: User Activity</span>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?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>
<span class="admin-badge">Admin</span> <span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
</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-left-corner">╚</span>
<span class="bottom-right-corner">╝</span> <span class="bottom-right-corner">╝</span>
@@ -34,37 +42,38 @@
<div class="ascii-content"> <div class="ascii-content">
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<!-- Date Range Filter --> <!-- Date Range Filter -->
<form method="GET" style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: flex-end;"> <form method="GET" class="admin-form-row">
<div> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date From</label> <label class="admin-label" for="date_from">Date From</label>
<input type="date" name="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="setting-select"> <input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="admin-input">
</div> </div>
<div> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date To</label> <label class="admin-label" for="date_to">Date To</label>
<input type="date" name="date_to" value="<?php echo htmlspecialchars($dateRange['to'] ?? ''); ?>" class="setting-select"> <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> </div>
<button type="submit" class="btn">Apply</button>
<a href="?" class="btn btn-secondary">Reset</a>
</form> </form>
<!-- User Activity Table --> <!-- User Activity Table -->
<table style="width: 100%;"> <div class="table-wrapper">
<table>
<thead> <thead>
<tr> <tr>
<th>User</th> <th>User</th>
<th style="text-align: center;">Tickets Created</th> <th class="text-center">Tickets Created</th>
<th style="text-align: center;">Tickets Resolved</th> <th class="text-center">Tickets Resolved</th>
<th style="text-align: center;">Comments Added</th> <th class="text-center">Comments Added</th>
<th style="text-align: center;">Tickets Assigned</th> <th class="text-center">Tickets Assigned</th>
<th style="text-align: center;">Last Activity</th> <th class="text-center">Last Activity</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if (empty($userStats)): ?> <?php if (empty($userStats)): ?>
<tr> <tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);"> <td colspan="6" class="empty-state">No user activity data available.</td>
No user activity data available.
</td>
</tr> </tr>
<?php else: ?> <?php else: ?>
<?php foreach ($userStats as $user): ?> <?php foreach ($userStats as $user): ?>
@@ -72,22 +81,22 @@
<td> <td>
<strong><?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?></strong> <strong><?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?></strong>
<?php if ($user['is_admin']): ?> <?php if ($user['is_admin']): ?>
<span class="admin-badge" style="font-size: 0.7rem;">Admin</span> <span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td style="text-align: center;"> <td class="text-center">
<span style="color: var(--terminal-green); font-weight: bold;"><?php echo $user['tickets_created'] ?? 0; ?></span> <span class="text-green fw-bold"><?php echo $user['tickets_created'] ?? 0; ?></span>
</td> </td>
<td style="text-align: center;"> <td class="text-center">
<span style="color: var(--status-open); font-weight: bold;"><?php echo $user['tickets_resolved'] ?? 0; ?></span> <span class="text-open fw-bold"><?php echo $user['tickets_resolved'] ?? 0; ?></span>
</td> </td>
<td style="text-align: center;"> <td class="text-center">
<span style="color: var(--terminal-cyan); font-weight: bold;"><?php echo $user['comments_added'] ?? 0; ?></span> <span class="text-cyan fw-bold"><?php echo $user['comments_added'] ?? 0; ?></span>
</td> </td>
<td style="text-align: center;"> <td class="text-center">
<span style="color: var(--terminal-amber); font-weight: bold;"><?php echo $user['tickets_assigned'] ?? 0; ?></span> <span class="text-amber fw-bold"><?php echo $user['tickets_assigned'] ?? 0; ?></span>
</td> </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'; ?> <?php echo $user['last_activity'] ? date('M d, Y H:i', strtotime($user['last_activity'])) : 'Never'; ?>
</td> </td>
</tr> </tr>
@@ -95,41 +104,32 @@
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div>
<!-- Summary Stats --> <!-- Summary Stats -->
<?php if (!empty($userStats)): ?> <?php if (!empty($userStats)): ?>
<div style="margin-top: 2rem; padding: 1rem; border: 1px solid var(--terminal-green);"> <div class="admin-stats-grid">
<h4 style="color: var(--terminal-amber); margin-bottom: 1rem;">Summary</h4> <div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; text-align: center;"> <div class="admin-stat-value text-green"><?php echo array_sum(array_column($userStats, 'tickets_created')); ?></div>
<div> <div class="admin-stat-label">Total Created</div>
<div style="font-size: 1.5rem; color: var(--terminal-green); font-weight: bold;"> </div>
<?php echo array_sum(array_column($userStats, 'tickets_created')); ?> <div>
</div> <div class="admin-stat-value text-open"><?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?></div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Created</div> <div class="admin-stat-label">Total Resolved</div>
</div> </div>
<div> <div>
<div style="font-size: 1.5rem; color: var(--status-open); font-weight: bold;"> <div class="admin-stat-value text-cyan"><?php echo array_sum(array_column($userStats, 'comments_added')); ?></div>
<?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?> <div class="admin-stat-label">Total Comments</div>
</div> </div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Resolved</div> <div>
</div> <div class="admin-stat-value text-amber"><?php echo count($userStats); ?></div>
<div> <div class="admin-stat-label">Active Users</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> </div>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
</body> </body>
</html> </html>

View File

@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Workflow Designer - Admin</title> <title>Workflow Designer - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="/assets/css/base.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; ?>"> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script> </script>
@@ -21,47 +24,47 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<body> <body>
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a> <a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Workflow Designer</span> <span class="admin-page-title">Admin: Workflow Designer</span>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?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>
<span class="admin-badge">Admin</span> <span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
</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-left-corner">╚</span>
<span class="bottom-right-corner">╝</span> <span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Status Workflow Designer</div> <div class="ascii-section-header">Status Workflow Designer</div>
<div class="ascii-content"> <div class="ascii-content">
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> <div class="admin-header-row">
<h2 style="margin: 0;">Status Transitions</h2> <h2>Status Transitions</h2>
<button data-action="show-create-modal" class="btn">+ New Transition</button> <button data-action="show-create-modal" class="btn">+ NEW TRANSITION</button>
</div> </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. Define which status transitions are allowed. This controls what options appear in the status dropdown.
</p> </p>
<!-- Visual Workflow Diagram --> <!-- Visual Workflow Diagram -->
<div style="margin-bottom: 2rem; padding: 1rem; border: 1px solid var(--terminal-green); background: var(--bg-secondary);"> <div class="workflow-diagram">
<h4 style="color: var(--terminal-amber); margin-bottom: 1rem;">Workflow Diagram</h4> <h4 class="admin-section-title">Workflow Diagram</h4>
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap;"> <div class="workflow-diagram-nodes">
<?php <?php
$statuses = ['Open', 'Pending', 'In Progress', 'Closed']; $statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
foreach ($statuses as $status): foreach ($statuses as $status):
$statusClass = 'status-' . str_replace(' ', '-', strtolower($status)); $statusClass = 'status-' . str_replace(' ', '-', strtolower($status));
?> ?>
<div style="text-align: center;"> <div class="workflow-diagram-node">
<div class="<?php echo $statusClass; ?>" style="padding: 0.5rem 1rem; display: inline-block;"> <div class="<?php echo $statusClass; ?>">
<?php echo $status; ?> <?php echo $status; ?>
</div> </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 <?php
$toCount = 0; $toCount = 0;
if (isset($workflows)) { if (isset($workflows)) {
@@ -78,7 +81,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
<!-- Transitions Table --> <!-- Transitions Table -->
<table style="width: 100%;"> <div class="table-wrapper">
<table>
<thead> <thead>
<tr> <tr>
<th>From Status</th> <th>From Status</th>
@@ -93,9 +97,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody> <tbody>
<?php if (empty($workflows)): ?> <?php if (empty($workflows)): ?>
<tr> <tr>
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);"> <td colspan="7" class="empty-state">No transitions defined. Add transitions to enable status changes.</td>
No transitions defined. Add transitions to enable status changes.
</td>
</tr> </tr>
<?php else: ?> <?php else: ?>
<?php foreach ($workflows as $wf): ?> <?php foreach ($workflows as $wf): ?>
@@ -105,42 +107,43 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php echo htmlspecialchars($wf['from_status']); ?> <?php echo htmlspecialchars($wf['from_status']); ?>
</span> </span>
</td> </td>
<td style="text-align: center; color: var(--terminal-amber);">→</td> <td class="text-amber text-center">→</td>
<td> <td>
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['to_status'])); ?>"> <span class="status-<?php echo str_replace(' ', '-', strtolower($wf['to_status'])); ?>">
<?php echo htmlspecialchars($wf['to_status']); ?> <?php echo htmlspecialchars($wf['to_status']); ?>
</span> </span>
</td> </td>
<td style="text-align: center;"><?php echo $wf['requires_comment'] ? '✓' : ''; ?></td> <td class="text-center"><?php echo $wf['requires_comment'] ? '✓' : ''; ?></td>
<td style="text-align: center;"><?php echo $wf['requires_admin'] ? '✓' : ''; ?></td> <td class="text-center"><?php echo $wf['requires_admin'] ? '✓' : ''; ?></td>
<td style="text-align: center;"> <td class="text-center">
<span style="color: <?php echo $wf['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;"> <span class="<?php echo $wf['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $wf['is_active'] ? '✓' : '✗'; ?> <?php echo $wf['is_active'] ? '✓' : '✗'; ?>
</span> </span>
</td> </td>
<td> <td>
<button data-action="edit-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small">Edit</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> <button data-action="delete-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Create/Edit Modal --> <!-- Create/Edit Modal -->
<div class="settings-modal" id="workflowModal" style="display: none;" data-action="close-modal-backdrop"> <div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="settings-content" style="max-width: 450px;"> <div class="lt-modal lt-modal-sm">
<div class="settings-header"> <div class="lt-modal-header">
<h3 id="modalTitle">Create Transition</h3> <span class="lt-modal-title" id="modalTitle">Create Transition</span>
<button class="close-settings" data-action="close-modal">×</button> <button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div> </div>
<form id="workflowForm"> <form id="workflowForm">
<input type="hidden" id="transition_id" name="transition_id"> <input type="hidden" id="transition_id" name="transition_id">
<div class="settings-body"> <div class="lt-modal-body">
<div class="setting-row"> <div class="setting-row">
<label for="from_status">From Status *</label> <label for="from_status">From Status *</label>
<select id="from_status" name="from_status" required> <select id="from_status" name="from_status" required>
@@ -169,15 +172,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label> <label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
</div> </div>
</div> </div>
<div class="settings-footer"> <div class="lt-modal-footer">
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button> <button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
const workflows = <?php echo json_encode($workflows ?? []); ?>; const workflows = <?php echo json_encode($workflows ?? []); ?>;
@@ -186,11 +188,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
document.getElementById('workflowForm').reset(); document.getElementById('workflowForm').reset();
document.getElementById('transition_id').value = ''; document.getElementById('transition_id').value = '';
document.getElementById('is_active').checked = true; document.getElementById('is_active').checked = true;
document.getElementById('workflowModal').style.display = 'flex'; lt.modal.open('workflowModal');
} }
function closeModal() { function closeModal() {
document.getElementById('workflowModal').style.display = 'none'; lt.modal.close('workflowModal');
} }
// Event delegation for data-action handlers // Event delegation for data-action handlers
@@ -203,12 +205,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
case 'show-create-modal': case 'show-create-modal':
showCreateModal(); showCreateModal();
break; break;
case 'close-modal':
closeModal();
break;
case 'close-modal-backdrop':
if (event.target === target) closeModal();
break;
case 'edit-transition': case 'edit-transition':
editTransition(target.dataset.id); editTransition(target.dataset.id);
break; break;
@@ -223,12 +219,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
saveTransition(e); saveTransition(e);
}); });
// Close modal on ESC key if (window.lt) lt.keys.initDefaults();
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
function saveTransition(e) { function saveTransition(e) {
e.preventDefault(); e.preventDefault();
@@ -241,25 +232,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
is_active: document.getElementById('is_active').checked ? 1 : 0 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 : ''); const url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
const apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
fetch(url, { apiCall.then(result => {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
if (result.success) { if (result.success) {
window.location.reload(); window.location.reload();
} else { } else {
toast.error(result.error || 'Failed to save'); lt.toast.error(result.error || 'Failed to save');
} }
}); }).catch(err => lt.toast.error('Failed to save'));
} }
function editTransition(id) { function editTransition(id) {
@@ -273,18 +254,16 @@ $nonce = SecurityHeadersMiddleware::getNonce();
document.getElementById('requires_admin').checked = wf.requires_admin == 1; document.getElementById('requires_admin').checked = wf.requires_admin == 1;
document.getElementById('is_active').checked = wf.is_active == 1; document.getElementById('is_active').checked = wf.is_active == 1;
document.getElementById('modalTitle').textContent = 'Edit Transition'; document.getElementById('modalTitle').textContent = 'Edit Transition';
document.getElementById('workflowModal').style.display = 'flex'; lt.modal.open('workflowModal');
} }
function deleteTransition(id) { function deleteTransition(id) {
if (!confirm('Delete this status transition?')) return; showConfirmModal('Delete Transition', 'Delete this status transition?', 'error', function() {
fetch('/api/manage_workflows.php?id=' + id, { lt.api.delete('/api/manage_workflows.php?id=' + id)
method: 'DELETE', .then(data => {
headers: { 'X-CSRF-Token': window.CSRF_TOKEN } if (data.success) window.location.reload();
}) else lt.toast.error(data.error || 'Failed to delete');
.then(r => r.json()) }).catch(err => lt.toast.error('Failed to delete'));
.then(data => {
if (data.success) window.location.reload();
}); });
} }
</script> </script>