Compare commits

...

37 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
29 changed files with 4431 additions and 1752 deletions

View File

@@ -126,6 +126,11 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `Ctrl/Cmd + E` | Toggle edit mode (ticket page) | | `Ctrl/Cmd + 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 |
@@ -242,17 +247,20 @@ tinker_tickets/
│ └── upload_attachment.php # GET/POST: List or upload attachments │ └── upload_attachment.php # GET/POST: List or upload attachments
├── assets/ ├── assets/
│ ├── css/ │ ├── css/
│ │ ├── base.css # LotusGuild Terminal Design System (symlinked from web_template)
│ │ ├── dashboard.css # Dashboard + terminal styling │ │ ├── dashboard.css # Dashboard + terminal styling
│ │ └── ticket.css # Ticket view styling │ │ └── ticket.css # Ticket view styling
│ ├── js/ │ ├── js/
│ │ ├── advanced-search.js # Advanced search modal │ │ ├── advanced-search.js # Advanced search modal
│ │ ├── ascii-banner.js # ASCII art banner │ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
│ │ ├── base.js # LotusGuild JS utilities — window.lt (symlinked from web_template)
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar │ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts │ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe) │ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
│ │ ├── settings.js # User preferences │ │ ├── settings.js # User preferences
│ │ ├── ticket.js # Ticket + comments + visibility │ │ ├── ticket.js # Ticket + comments + visibility
│ │ ── toast.js # Toast notifications │ │ ── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
│ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
│ └── images/ │ └── images/
│ └── favicon.png │ └── favicon.png
├── config/ ├── config/
@@ -387,6 +395,14 @@ Key conventions and gotchas for working with this codebase:
15. **CSP nonces**: All inline `<script>` tags require `nonce="<?php echo $nonce; ?>"` 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 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 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 Reference

View File

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

@@ -79,6 +79,17 @@ try {
]; ];
} }
// Authorization: admins can edit any ticket; others only their own or assigned
if (!$this->isAdmin
&& $currentTicket['created_by'] != $this->userId
&& $currentTicket['assigned_to'] != $this->userId
) {
return [
'success' => false,
'error' => 'Permission denied'
];
}
// Merge current data with updates, keeping existing values for missing fields // Merge current data with updates, keeping existing values for missing fields
$updateData = [ $updateData = [
'ticket_id' => $id, 'ticket_id' => $id,

View File

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

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

View File

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

View File

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

View File

@@ -33,43 +33,46 @@ function showKeyboardHelp() {
modal.id = 'keyboardHelpModal'; modal.id = 'keyboardHelpModal';
modal.className = 'lt-modal-overlay'; modal.className = 'lt-modal-overlay';
modal.setAttribute('aria-hidden', 'true'); 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="lt-modal" style="max-width: 500px;"> <div class="lt-modal lt-modal-sm">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title">KEYBOARD SHORTCUTS</span> <span class="lt-modal-title" id="keyboardHelpModalTitle">KEYBOARD SHORTCUTS</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<div class="lt-modal-body"> <div class="lt-modal-body">
<h4 style="color: var(--terminal-amber); margin: 0 0 0.5rem 0; font-size: 0.9rem;">Navigation</h4> <h4 class="kb-section-heading">Navigation</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;"> <table class="kb-shortcuts-table">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>J</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Next ticket in list</td></tr> <tr><td><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>K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Previous ticket in list</td></tr> <tr><td><kbd>K</kbd></td><td>Previous ticket in list</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Enter</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Open selected ticket</td></tr> <tr><td><kbd>Enter</kbd></td><td>Open selected ticket</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>G</kbd> then <kbd>D</kbd></td><td style="padding: 0.4rem;">Go to Dashboard</td></tr> <tr><td><kbd>G</kbd> then <kbd>D</kbd></td><td>Go to Dashboard</td></tr>
</table> </table>
<h4 style="color: var(--terminal-amber); margin: 0 0 0.5rem 0; font-size: 0.9rem;">Actions</h4> <h4 class="kb-section-heading">Actions</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;"> <table class="kb-shortcuts-table">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>N</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">New ticket</td></tr> <tr><td><kbd>N</kbd></td><td>New ticket</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>C</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus comment box</td></tr> <tr><td><kbd>C</kbd></td><td>Focus comment box</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd+E</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Toggle Edit Mode</td></tr> <tr><td><kbd>Ctrl/Cmd+E</kbd></td><td>Toggle Edit Mode</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>Ctrl/Cmd+S</kbd></td><td style="padding: 0.4rem;">Save Changes</td></tr> <tr><td><kbd>Ctrl/Cmd+S</kbd></td><td>Save Changes</td></tr>
</table> </table>
<h4 style="color: var(--terminal-amber); margin: 0 0 0.5rem 0; font-size: 0.9rem;">Quick Status (Ticket Page)</h4> <h4 class="kb-section-heading">Quick Status (Ticket Page)</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;"> <table class="kb-shortcuts-table">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>1</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Open</td></tr> <tr><td><kbd>1</kbd></td><td>Set Open</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>2</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Pending</td></tr> <tr><td><kbd>2</kbd></td><td>Set Pending</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>3</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set In Progress</td></tr> <tr><td><kbd>3</kbd></td><td>Set In Progress</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>4</kbd></td><td style="padding: 0.4rem;">Set Closed</td></tr> <tr><td><kbd>4</kbd></td><td>Set Closed</td></tr>
</table> </table>
<h4 style="color: var(--terminal-amber); margin: 0 0 0.5rem 0; font-size: 0.9rem;">Other</h4> <h4 class="kb-section-heading">Other</h4>
<table style="width: 100%; border-collapse: collapse;"> <table class="kb-shortcuts-table no-margin">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd+K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus Search</td></tr> <tr><td><kbd>Ctrl/Cmd+K</kbd></td><td>Focus Search</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>ESC</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Close Modal / Cancel</td></tr> <tr><td><kbd>ESC</kbd></td><td>Close Modal / Cancel</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>?</kbd></td><td style="padding: 0.4rem;">Show This Help</td></tr> <tr><td><kbd>?</kbd></td><td>Show This Help</td></tr>
</table> </table>
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button class="lt-btn lt-btn-ghost" data-modal-close>Close</button> <button class="lt-btn lt-btn-ghost" data-modal-close>CLOSE</button>
</div> </div>
</div> </div>
`; `;

View File

@@ -364,7 +364,7 @@ function createEditorToolbar(textareaId, containerId) {
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button> <button type="button" data-toolbar-action="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

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

View File

@@ -10,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,19 +0,0 @@
#!/bin/bash
set -e
echo "Deploying tinker_tickets to web server..."
# Deploy web_template (shared UI framework)
echo "Syncing web_template to web server..."
rsync -avz --delete --exclude='.git' --exclude='node' --exclude='php' --exclude='python' --exclude='README.md' --exclude='Claude.md' /root/code/web_template/ root@10.10.10.45:/var/www/html/web_template/
# 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/web_template /var/www/html/tinkertickets && find /var/www/html/web_template /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/web_template /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

@@ -11,12 +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="/web_template/base.css"> <link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<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/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.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/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/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(); ?>';
@@ -25,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>
@@ -48,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>
@@ -62,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 htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?> <strong>[ ! ] ERROR:</strong> <?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?>
</div> </div>
</div> </div>
</div> </div>
@@ -90,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>
@@ -109,11 +109,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket"> <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>
@@ -185,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>
@@ -206,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';
@@ -220,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>
@@ -258,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>
@@ -277,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;
} }
@@ -288,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 => {
@@ -323,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);
} }
} }
@@ -352,6 +351,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
toggleVisibilityGroups(); toggleVisibilityGroups();
} }
}); });
if (window.lt) lt.keys.initDefaults();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -12,14 +12,13 @@ $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="/web_template/base.css"> <link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.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/ascii-banner.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/toast.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=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/dashboard.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; ?>"> <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(); ?>';
@@ -33,41 +32,44 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<!-- 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)
@@ -80,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 -->
@@ -163,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>
@@ -180,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>
@@ -203,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>
@@ -252,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'])): ?>
@@ -273,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>
@@ -287,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>
@@ -310,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>
@@ -352,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; ?>
@@ -394,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; ?>
@@ -408,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',
@@ -430,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>";
} }
} }
?> ?>
@@ -452,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";
@@ -509,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
@@ -541,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">
@@ -578,7 +561,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="settingsModalTitle"> <div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="settingsModalTitle">
<div class="lt-modal"> <div class="lt-modal">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title" id="settingsModalTitle">⚙ System Preferences</span> <span class="lt-modal-title" id="settingsModalTitle">[ CFG ] SYSTEM PREFERENCES</span>
<button class="lt-modal-close" data-modal-close aria-label="Close settings">✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close settings">✕</button>
</div> </div>
@@ -724,8 +707,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" data-action="save-settings">Save Preferences</button> <button class="lt-btn lt-btn-primary" data-action="save-settings">SAVE PREFERENCES</button>
<button class="lt-btn lt-btn-ghost" data-action="close-settings">Cancel</button> <button class="lt-btn lt-btn-ghost" data-action="close-settings">CANCEL</button>
</div> </div>
</div> </div>
</div> </div>
@@ -734,7 +717,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="lt-modal-overlay" id="advancedSearchModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="advancedSearchModalTitle"> <div class="lt-modal-overlay" id="advancedSearchModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="advancedSearchModalTitle">
<div class="lt-modal"> <div class="lt-modal">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title" id="advancedSearchModalTitle">🔍 Advanced Search</span> <span class="lt-modal-title" id="advancedSearchModalTitle">[ FILTER ] ADVANCED SEARCH</span>
<button class="lt-modal-close" data-modal-close aria-label="Close advanced search">✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close advanced search">✕</button>
</div> </div>
@@ -750,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,17 +824,17 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">Search</button> <button type="submit" class="lt-btn lt-btn-primary">SEARCH</button>
<button type="button" class="lt-btn lt-btn-ghost" data-action="reset-advanced-search">Reset</button> <button type="button" class="lt-btn lt-btn-ghost" data-action="reset-advanced-search">RESET</button>
<button type="button" class="lt-btn lt-btn-ghost" 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) // Initialize lt keyboard defaults (ESC closes modals, Ctrl+K focuses search, ? shows help)
if (window.lt) lt.keys.initDefaults(); if (window.lt) lt.keys.initDefaults();
@@ -879,6 +862,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
openSettingsModal(); openSettingsModal();
break; break;
case 'manual-refresh':
lt.autoRefresh.now();
break;
case 'close-settings': case 'close-settings':
closeSettingsModal(); closeSettingsModal();
break; break;
@@ -887,9 +874,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
saveSettings(); saveSettings();
break; break;
case 'toggle-banner':
toggleBanner();
break;
case 'toggle-sidebar': case 'toggle-sidebar':
toggleSidebar(); toggleSidebar();
@@ -1008,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,15 +50,14 @@ $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="/web_template/base.css"> <link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<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/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.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/toast.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=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/dashboard.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/ticket.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; ?>"> <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(); ?>';
@@ -82,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>
@@ -134,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">
@@ -142,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>";
} }
} }
?> ?>
@@ -221,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):
@@ -244,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>
@@ -274,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>
@@ -294,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>
@@ -323,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>
@@ -362,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>";
@@ -422,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>
@@ -463,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>
@@ -498,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">
@@ -558,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';
});
}
}); });
} }
@@ -685,7 +684,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="ticketSettingsTitle"> <div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="ticketSettingsTitle">
<div class="lt-modal"> <div class="lt-modal">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title" id="ticketSettingsTitle">⚙ System Preferences</span> <span class="lt-modal-title" id="ticketSettingsTitle">[ CFG ] SYSTEM PREFERENCES</span>
<button class="lt-modal-close" data-modal-close aria-label="Close settings">✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close settings">✕</button>
</div> </div>
@@ -808,14 +807,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="saveSettingsBtn">Save Preferences</button> <button class="lt-btn lt-btn-primary" id="saveSettingsBtn">SAVE PREFERENCES</button>
<button class="lt-btn lt-btn-ghost" id="cancelSettingsBtn">Cancel</button> <button class="lt-btn lt-btn-ghost" id="cancelSettingsBtn">CANCEL</button>
</div> </div>
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script> <script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
</body> </body>
</html> </html>

View File

@@ -12,11 +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="/web_template/base.css"> <link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.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/toast.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script> </script>
@@ -24,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>
@@ -61,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>
@@ -98,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>
@@ -149,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>
@@ -187,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);
} }
}); });
@@ -228,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,26 +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="/web_template/base.css"> <link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script src="/web_template/base.js"></script> <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>
@@ -36,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>
@@ -51,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' : ''; ?>>
@@ -62,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>
@@ -92,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']);
} }
@@ -128,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) {
@@ -155,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,10 +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="/web_template/base.css"> <link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.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?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>
@@ -23,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>
@@ -62,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): ?>
@@ -76,29 +76,30 @@ $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="lt-modal-overlay" id="fieldModal" aria-hidden="true"> <div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal" style="max-width: 500px;"> <div class="lt-modal lt-modal-sm">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Custom Field</span> <span class="lt-modal-title" id="modalTitle">Create Custom Field</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </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">
@@ -122,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>
@@ -149,14 +150,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">Save</button> <button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button> <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';
@@ -208,7 +208,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
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) {
@@ -230,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 {
lt.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;
@@ -276,14 +265,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
} }
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,10 +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="/web_template/base.css"> <link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.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?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>
@@ -23,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>
@@ -62,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): ?>
@@ -81,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="lt-modal-overlay" id="recurringModal" aria-hidden="true"> <div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal" style="max-width: 800px; width: 90%;"> <div class="lt-modal lt-modal-lg">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Recurring Ticket</span> <span class="lt-modal-title" id="modalTitle">Create Recurring Ticket</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </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="lt-modal-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>
@@ -134,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>
@@ -142,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">
@@ -184,14 +185,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">Save</button> <button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button> <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';
@@ -251,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>`;
} }
@@ -271,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 {
lt.toast.error(data.error || 'Failed to save'); lt.toast.error(result.error || 'Failed to save');
} }
}); }).catch(err => lt.toast.error('Failed to save'));
} }
function toggleRecurring(id) { 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;
@@ -340,8 +324,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
// 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,10 +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="/web_template/base.css"> <link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.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?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>
@@ -23,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>
@@ -64,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): ?>
@@ -76,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="lt-modal-overlay" id="templateModal" aria-hidden="true"> <div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal" style="max-width: 800px; width: 90%;"> <div class="lt-modal lt-modal-lg">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Template</span> <span class="lt-modal-title" id="modalTitle">Create Template</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </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="lt-modal-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">
@@ -155,14 +156,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">Save</button> <button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button> <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 ?? []); ?>;
@@ -217,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 {
lt.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) {
@@ -255,14 +245,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
} }
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,26 +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="/web_template/base.css"> <link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script src="/web_template/base.js"></script> <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>
@@ -36,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): ?>
@@ -74,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>
@@ -97,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,10 +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="/web_template/base.css"> <link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.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?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>
@@ -23,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)) {
@@ -80,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>
@@ -95,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): ?>
@@ -107,38 +107,39 @@ $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="lt-modal-overlay" id="workflowModal" aria-hidden="true"> <div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal" style="max-width: 450px;"> <div class="lt-modal lt-modal-sm">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Transition</span> <span class="lt-modal-title" id="modalTitle">Create Transition</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </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">
@@ -172,14 +173,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">Save</button> <button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button> <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 ?? []); ?>;
@@ -232,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 {
lt.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) {
@@ -268,14 +258,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
} }
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>