38 Commits

Author SHA1 Message Date
jared 4150e1ced3 Fix lt-scanlines header overlap — move class off body to dedicated div
body::before and body::after are used for background grid/gradient effects.
Adding lt-scanlines to body caused ::after conflict (higher specificity) and
put the scanline overlay at z-index 9998, above the header at z-index 300.

Move lt-scanlines to a dedicated fixed div so pseudo-elements don't conflict
and the header remains fully visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:12:07 -04:00
jared cfdc9e0f37 Sync TDS v1.2 additions: scanlines, cursor, radar, display-field, VT323
- Sync base.css + base.js from web_template (adds lt-scanlines,
  lt-cursor, lt-radar, lt-display-field, --font-crt/VT323 token)
- Add VT323 to Google Fonts link in layout_header.php
- Add lt-scanlines to <body> — CRT scanline overlay, light-mode suppressed
- Replace custom .editable-metadata:disabled CSS override in ticket.css
  with the canonical .lt-display-field class from base.css
- Switch Priority/Category/Type/Visibility selects and visibility-group
  checkboxes in TicketView.php from disabled attribute to lt-display-field
- Update toggleEditMode() in ticket.js to add/remove lt-display-field
  instead of toggling the disabled attribute

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 16:55:12 -04:00
jared 55c6fc81db Fix duplicate users in bulk/quick assign modals; add combobox search
Root cause: DashboardView.php and dashboard.js both had a global
document.addEventListener('click') handler handling the same bulk-assign
and quick-assign actions. Every click fired both handlers, creating two
modals and two API fetches that both appended to the same select element.

Fix: Remove duplicate cases (bulk-*, navigate, view-ticket, quick-*,
set-view-mode, toggle-*, clear-selection) from DashboardView.php's inline
handler. dashboard.js already handles all of these correctly.

Also replace <select> with lt.combobox in both bulk-assign and
quick-assign modals so large user lists are searchable instead of a
long scrolling dropdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:13:10 -04:00
jared fdc6d3d463 Fix ASCII art alignment, readonly input opacity, api key visibility
Use white-space:pre-wrap on description view div so newlines and multiple
spaces are preserved natively — no <br> replacement, ASCII art aligns
correctly since body is already monospace (JetBrains Mono).

Override opacity:1 on readonly API key input so generated keys are fully
readable instead of being faded to 0.45 by base.css [readonly] rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:43:18 -04:00
jared 72d5061867 Fix description line breaks and disabled-field readability
Ticket descriptions are plain text — renderDescriptionView() now always
uses nl2br instead of parseMarkdown(), preventing markdown from mangling
single newlines into run-on paragraphs.

Override base.css opacity:0.45 on disabled .editable-metadata selects
(Priority, Category, Type) so they remain legible at full contrast on
dark/OLED screens in read mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:36:10 -04:00
jared 1d721eecb4 fix: description unreadable in dark mode / OLED — swap disabled textarea for lt-markdown div
Root cause: disabled textarea gets opacity:0.45 + color:var(--text-muted) from
base.css, making it near-invisible on OLED (true-black background).

Fix:
- TicketView: add #ticketDescriptionView (div.lt-markdown) alongside the textarea;
  textarea is now hidden by default (style="display:none"), view div is shown
- ticket.js: renderDescriptionView() renders raw text via parseMarkdown() or nl2br;
  showDescriptionView() / showDescriptionEdit() swap between them;
  toggleEditMode() calls showDescriptionEdit() when entering edit, and
  renderDescriptionView() + showDescriptionView() when returning to read mode
- ticket.css: .ticket-description-view sets full-contrast text-primary/secondary
  colors, min-height, and line-height for comfortable reading

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 18:10:39 -04:00
jared cfb88d9c88 fix: CSRF token staleness causing intermittent 403 on POST actions
Root cause: bootstrap.php rotates the CSRF token on every successful POST,
but most API endpoints called echo json_encode() directly instead of
apiRespond() — so the rotated token was never returned to the client.
The next POST from the same page sent the now-invalid old token → 403.
Refreshing the page loaded a fresh token, making it work once.

Fixes:
- assign_ticket.php, watch_ticket.php: switch to apiRespond()
- saved_filters.php, user_preferences.php: replace all echo json_encode
  calls with apiRespond() (19 and 12 call sites respectively)
- base.js: both apiFetch() and _apiFetchAuth() now update window.CSRF_TOKEN
  whenever a response includes a csrf_token field, keeping the client
  permanently in sync with server-side rotations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:01:18 -04:00
jared a89095cbcc chore: delete applied migrations (001-002, 004-005 all applied to DB)
All SQL migration files have been applied and recorded in the migrations
tracking table. Folder intentionally empty — migrate.php kept as runner
for future one-time schema changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:03:16 -04:00
jared ade1a70214 feat: ticket watchers, fulltext search, single-query pagination, watcher notifications
Ticket watchers:
- api/watch_ticket.php: GET (watch state) + POST (watch/unwatch toggle)
- index.php: route for /api/watch_ticket.php
- TicketView: WATCH/UNWATCH button with live state fetch and toggle
- NotificationHelper::notifyWatchers(): fetches watchers from DB, resolves
  Matrix IDs via Synapse, fires notification to watchers + global list
- add_comment.php, update_ticket.php: call notifyWatchers on comment and
  status-change events respectively

Fulltext search:
- TicketModel::hasFulltextIndex(): detects FULLTEXT index via information_schema
- getAllTickets(): uses MATCH...AGAINST when fulltext index exists, LIKE fallback
  when not yet applied — zero-downtime rollout

Single-query pagination:
- getAllTickets() replaces separate COUNT + SELECT with COUNT(*) OVER() window
  function — one round trip to DB per page load instead of two

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:00:32 -04:00
jared 0acf5e84c3 feat: duplicate link action, watcher migration, fulltext search migration
- CreateTicketView: "Link as duplicate" button on each duplicate result;
  stores chosen ticket ID in hidden field, auto-creates duplicates dependency
  after ticket is saved (TicketController)
- migrations/004: ticket_watchers table (ticket_id, user_id primary key)
- migrations/005: FULLTEXT index on tickets(title, description) for fast
  relevance search replacing LIKE scan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:54:00 -04:00
jared c8181e8076 feat: comment pagination, Matrix integration, Synapse mention resolution
Comment pagination:
- CommentModel: add getCommentCount(), paginated getCommentsByTicketId()
  with getThreadedCommentsPaged() for threading + LIMIT/OFFSET
- TicketController: load first 50 root comments + total count on page load
- api/get_comments.php: new AJAX endpoint for Load More (index.php routed)
- TicketView: Load More button + buildCommentEl() JS renderer for AJAX comments;
  passes totalComments/commentOffset/isAdmin to window.ticketData

Matrix integration:
- NotificationHelper: add sendStatusChangeNotification(), sendCommentNotification(),
  sendMentionNotification(), sendAssignmentNotification() alongside existing
  sendTicketNotification(); internal fire() helper replaces duplicated cURL logic
- SynapseHelper: new helper that resolves SSO usernames → Matrix IDs by querying
  Synapse Admin REST API directly (no caching, no stale data)
- config.php: add SYNAPSE_ADMIN_URL, SYNAPSE_ADMIN_TOKEN, MATRIX_NOTIFY_COMMENTS,
  MATRIX_NOTIFY_ASSIGNMENTS config keys (all from .env)
- api/update_ticket.php: fire status-change notification after successful save
- api/add_comment.php: resolve @mentioned usernames via SynapseHelper and fire
  mention notification; fire general comment notification when MATRIX_NOTIFY_COMMENTS=1
- api/assign_ticket.php: fire assignment notification (resolves assignee via Synapse)
  when MATRIX_NOTIFY_ASSIGNMENTS=1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:34:16 -04:00
jared cc3f667d4c Wire optimistic locking, visibility audit log, full ticket export
Optimistic locking:
- TicketView now includes updated_at in window.ticketData
- ticket.js saveTicket() sends expected_updated_at on every save so
  the server can detect concurrent edits
- On conflict response, shows a clear toast: "ticket was modified by
  someone else while you were editing — reload to see latest version"
- On success, syncs window.ticketData.updated_at from server response
  so subsequent saves use the correct lock key
- update_ticket.php now returns updated_at in success response

Visibility audit log:
- updateVisibility() result is now checked; on success, logs a delta
  entry to the audit trail with from/to visibility and groups so the
  timeline shows who changed visibility and when

Full ticket export:
- export_tickets.php now accepts format=full with a single ticket_id
- Produces a JSON file containing ticket fields, flat comment list
  (with author, timestamps, text), and the full audit timeline
- Access-controlled: respects canUserAccessTicket() before exporting
- EXPORT button added to ticket toolbar linking directly to the endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:19:01 -04:00
jared 2fdd42b45b UX and architecture fixes: bulk-delete, template guard, statuses config
Bug fixes:
- bulk-delete action called undefined bulkDelete() — wired to the
  existing showBulkDeleteModal() so the confirmation modal actually shows

UX:
- Template loader now checks for existing title/description and asks
  for confirmation before overwriting user-typed content
- Visibility select shows a dynamic hint paragraph that updates when
  the user changes the selection (public/internal/confidential)

Architecture:
- TICKET_STATUSES added to config as single source of truth; all
  hardcoded ['Open','Pending','In Progress','Closed'] arrays in
  DashboardView now read from config; bulk-status modal in dashboard.js
  reads window.TICKET_STATUSES (set from PHP) with array fallback
- ASSET_VERSION now auto-computed from max mtime of dashboard/ticket
  CSS+JS files so browsers always pick up changes on deploy; manual
  override still available via ASSET_VERSION in .env
- Removed 10 dead standalone stat methods from StatsModel (getOpenTicketCount,
  getClosedTicketCount, getTicketsByPriority, etc.) — all superseded by
  the consolidated fetchAllStats() queries, never called externally

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:09:29 -04:00
jared 277daf6f00 Remove dead TicketController::update() method
No route in index.php ever invokes this method — all ticket updates
go through api/update_ticket.php. The method also lacked authorization
checks, making its removal strictly safer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:32:55 -04:00
jared f709e98bd3 Security: add authorization checks to ticket_dependencies API
- POST /ticket_dependencies: verify user can access both the source
  ticket and the target ticket before creating a dependency
- DELETE by ticket IDs: verify user can access source ticket; also
  validate dependency_type against the allowed whitelist
- DELETE by dependency_id: look up dependency's ticket before deletion
  and verify user can access it, preventing IDOR
- custom_fields.php: validate json_decode returns an array on POST/PUT;
  add http_response_code(400) to all error responses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:26:01 -04:00
jared e6b6a2a88c Security/correctness: visibility filtering, Content-Type headers, group validation
- TicketModel::getAllTickets() now accepts optional $user param and applies
  getVisibilityFilter() so non-admin users cannot see internal/confidential
  tickets they lack access to from the dashboard listing
- DashboardController passes $GLOBALS['currentUser'] to getAllTickets()
- clone_ticket.php: move Content-Type header to top so all error paths send
  correct JSON content type
- AuthMiddleware: filter group names from HTTP header to [a-z0-9_-] only,
  preventing header injection via malformed group names
- add_comment.php: return HTTP 201 on success, 500 in catch block
- update_comment.php, delete_comment.php: return 500 in catch blocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:23:16 -04:00
jared f983269f93 Fix file upload security, bind_param mismatch, and cookie flags
- upload_attachment.php: derive stored file extension from validated MIME type
  instead of user-supplied filename, preventing executable extension attacks
  (e.g. a PHP file renamed to evil.txt would now be stored as .txt)
- CustomFieldModel.php: fix bind_param type string in updateDefinition()
  'sssssiiiii' (10 chars) → 'sssssiiii' (9 chars) to match 9 SQL placeholders
- RateLimitMiddleware.php: replace MD5 with SHA256 for rate limit file hashing
- user_preferences.php: add httponly, secure, samesite=Lax flags to ticketsPerPage
  cookie to prevent XSS/CSRF cookie theft

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:14:18 -04:00
jared 7be283423a Fix loose comparisons, missing response codes, and session handling
- ticket.js: escape dependency_id with lt.escHtml() in data attribute
- assign_ticket.php: strict (int) cast for ticket_id (> 0 check), authorization
  comparisons, and add missing http_response_code(400) on invalid user ID
- TicketView.php: strict (int) cast for priority select, assigned_to select,
  and comment ownership check
- CommentModel.php: strict (int) cast for parent_comment_id thread comparison
- UserModel.php: strict (int) cast for is_admin check
- export_tickets.php: conditional session_start() to avoid double-start warning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 17:39:46 -04:00
jared 2e450dc01d Apply web_template gap analysis improvements (P1-P3)
P1-A: Fix CSP - add fonts.googleapis.com to style-src, fonts.gstatic.com to font-src
P1-B: CSRF token rotation - add rotateToken() to CsrfMiddleware; bootstrap.php rotates
      after successful validation and stores in $GLOBALS['_new_csrf_token']; add
      apiRespond() helper to append token to responses; lt.api interceptor in
      layout_footer.php auto-updates window.CSRF_TOKEN from responses
P1-C: Styled 403/404 error views with TDS layout instead of raw text; index.php now
      uses requireAdmin() helper eliminating 7 duplicated guard blocks (P3-D)
P2-A: Remove duplicate JS-generated keyboard help modal from keyboard-shortcuts.js;
      '?' key now routes to static #lt-keys-help modal in footer
P2-B: Asset versioning driven by config ASSET_VERSION key; base.css and base.js get
      ?v= cache-busting in layout_header.php
P2-C: Add data-theme="dark" to <html> tag to prevent FOUC on light-mode users
P2-E: Escape status value in dashboard.js hover preview class attribute via lt.escHtml()
P2-F: Replace bespoke showLoadingOverlay() with lt-spinner / lt-loading-text from
      base.css; add .lt-loading-overlay wrapper CSS to dashboard.css
P2-G: Add keyboard-shortcuts.js to all 7 admin views so J/K nav and ? help work
P3-A: APP_NAME, APP_SUBTITLE, APP_VERSION driven from config.php; layout header/footer
      use config values instead of hardcoded strings
P3-G: Replace custom initTableSorting() with lt.sortTable.init() which manages aria-sort

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 17:02:40 -04:00
jared f0abadfc57 Fix 500 error for non-admin users on dashboard
StatsModel queries used `FROM tickets WHERE` with no table alias, but
getVisibilityFilter() returns SQL referencing `t.visibility`. Admins
were unaffected because they get `1=1` with no column references.
Added `t` alias to all three tickets queries that use $visSQL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:42:39 -04:00
jared d33f761a55 Fix loose comparisons in authorization checks
- TicketModel.php: fix bind_param "sssi"→"issi" for ticketId in addComment()
- TicketModel.php: use strict (int) cast === for confidential ticket access check
- update_ticket.php: use strict (int) cast !== for creator/assignee auth check
- AttachmentModel.php: use strict (int) cast === for upload ownership check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:35:48 -04:00
jared cfbef029cb Fix bind_param type mismatches and integer validation
- TemplateModel.php: fix bind_param "ssssiii" -> "sssssii" (5 strings not 4)
- manage_workflows.php: fix bind_param 'ssiiii' -> 'ssiiiii' (4 int columns)
- download_attachment.php, delete_attachment.php, get_template.php: replace is_numeric()
  with strict int cast+equality check to reject floats and scientific notation
- manage_recurring.php: validate JSON input before accessing schedule_type key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:33:48 -04:00
jared 5242d42fa7 Fix type safety and TDS class naming issues
- bulk_operation.php: replace is_numeric() with strict int cast+equality to reject scientific notation
- AttachmentModel.php: fix bind_param type strings (s→i for integer ticket IDs)
- CommentModel.php: use strict !== comparison with (int) cast for user_id ownership checks
- ticket.js: replace all non-TDS class names (text-amber→lt-text-amber, btn→lt-btn variants, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:29:28 -04:00
jared d8e6dcf7fa fix: CSS nesting conflict, dashboard.js dead code removal, admin view escaping
CSS:
- ticket.css: use combined .comment.thread-depth-N selectors to resolve the
  margin-left conflict between .comment-reply and .thread-depth-N classes

dashboard.js:
- Remove legacy initStatusFilter() (superseded by TDS v1.2 sidebar filters)
- Remove initTableSorting() call (client-side sort conflicts with server ?sort=)
- Remove quickSave() + saveTicket() (old hamburger-menu ticket page functions)
- Remove global loadTemplate() (duplicate of IIFE-scoped version in CreateTicketView)
- Remove generateSkeletonRows/Comments/Stats helpers (never called, used
  unregistered CSS class names like .skeleton-row-tr)
- Remove "force dark mode" lines that overrode the user theme preference
- Fix non-TDS CSS classes in modal templates: text-center → style, text-green →
  lt-text-cyan, mb-half → lt-mb-xs, modal-warning-text → lt-text-danger

Admin views:
- RecurringTicketsView: replace innerHTML += loop with createElement/appendChild
  (avoids serial DOM re-parsing on each iteration)
- AuditLogView: add htmlspecialchars() to action_type option values (consistency)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:34:34 -04:00
jared 6b76496640 fix: CSRF on ticket create form, DOM-safe duplicate list, audit-log param validation
- TicketController::create: validate csrf_token from POST before processing
- CreateTicketView: emit hidden csrf_token field; replace innerHTML duplicate
  list with DOM methods to prevent any XSS path; guard checkDuplicates() with
  lt.api availability check
- index.php audit-log: allowlist action_type; validate date_from/date_to as
  YYYY-MM-DD before passing to query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:26:52 -04:00
jared b40c404828 fix: ldap_get_entries returns raw binary, remove incorrect base64 decode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:53:49 -04:00
jared 18bf1fde0e feat: LDAP avatar support via lldap
- Create tinker-tickets service account in lldap (lldap_strict_readonly)
- Add /api/user_avatar.php: binds to lldap, fetches avatar attribute,
  caches JPEG to uploads/avatars/, returns 404 sentinel for missing photos
- Install php8.2-ldap on LXC 132 (beta) and LXC coding server
- Update layout_header.php: show lt-avatar with photo overlay + initials fallback
- Update TicketView.php: comment avatars use photo overlay pattern
- Add .lt-avatar-img / .lt-avatar-initials CSS for photo-over-initials layout
- Add LDAP_* config keys to config.php and .env.example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:47:08 -04:00
jared 87f878ee6b Fix XSS: escape user_name and created_at in reply DOM injection
submitReply() built a replyDiv.innerHTML template literal using
data.user_name (API response) without escaping — an attacker-controlled
display name could inject arbitrary HTML. Fix: wrap all API-sourced
string values in lt.escHtml() within the template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:42:27 -04:00
jared 82aa4bf5de Harden attachment deletion and template CRUD validation
- delete_attachment.php: add realpath() path traversal check before
  unlink() — mirrors the defense-in-depth already in download_attachment.php;
  also cast ticket_id to int when building the path
- manage_templates.php: add input validation to POST and PUT handlers:
  required field checks, max length caps (name 100, title 255, desc 64KB),
  allowlist validation for category/type, priority clamped to 1-5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:41:22 -04:00
jared e2c23d0405 Fix XSS: escape userName in reply form insertAdjacentHTML template
showReplyForm() read userName from data-user attribute (decoded by
the browser from HTML entities) and injected it unsanitized into
insertAdjacentHTML() — any HTML special chars would be parsed as markup.
Fix: wrap with lt.escHtml() before interpolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:38:30 -04:00
jared 170bd86aa6 Show only changed fields (delta) in ticket activity timeline
Before: entire ticket data was logged and shown in the activity tab.
After: compare old vs new values before saving; log only fields that
actually changed as { field: { from: '...', to: '...' } } pairs.

- TicketController.php: fetch old ticket before update, compute delta
- api/update_ticket.php: same fix for the API endpoint (currentTicket
  already fetched for auth, reuse it for delta comparison)
- TicketView.php: render delta format as "Field: old → new" with color;
  truncate long values (description) at 60 chars; keep legacy flat format
  as fallback for older log entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:35:01 -04:00
jared 3bb4792635 Fix header overlap, is-hidden missing globally, and CreateTicketView CSS
- base.css: add .lt-main.lt-container combined selector (specificity 0,2,0)
  to prevent responsive .lt-container padding shorthand from overriding
  the fixed-header clearance padding-top — affected all viewports < 1280px
- base.css: add .is-hidden { display: none !important } globally; it was
  only defined in ticket.css so dashboard ticketPreview popup rendered
  as a green box at 0,0 on page load instead of being hidden
- CreateTicketView.php: add dashboard.css to pageStyles so create-ticket-
  meta-grid, lt-form-hint, visibility-groups-list, duplicate-list classes
  are available (they were undefined when only ticket.css was loaded)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:30:00 -04:00
jared b42597c927 Fix CSS variables, missing utility classes, API hardening, and audit log UX
- base.css: add --lt-border/--lt-surface aliases so dashboard.css respects
  theme instead of using hardcoded fallback colors
- base.css: add lt-select-sm/lt-input-sm compact size variants (used in 15+
  places), lt-msg-danger alias for lt-msg-error, lt-form-hint--warn,
  lt-font-mono utility class
- audit_log.php: cap ?limit= at 500 to prevent DoS via oversized queries
- ApiKeysView.php: replace deprecated execCommand('copy') with lt.copy();
  add integer casts on api_key_id in id attr and data-id
- AuditLogView.php: rebuild pagination with windowed prev/next/ellipsis
  pattern matching DashboardView; integer cast on user_id select option

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:22:12 -04:00
jared e721b33911 Align UI with web_template TDS v1.2 standards
- Replace lt-chip priority badges with lt-badge lt-badge-p[1-4] across
  DashboardView, TemplatesView (matches web_template sticky table pattern)
- Add lt-theme-btn theme toggle to header-right; wire lt.theme.toggle()
- Replace ASCII art empty state with lt-empty-state component in dashboard
- Standardize tab wrapper lt-tabs → lt-tab-bar in Dashboard and TicketView
- Add missing lt-keys-help modal to layout_footer (fixes ? key doing nothing)
- Add lt-cmd-overlay command palette container + lt.cmdPalette.init() nav
- Add .lt-timeline-action CSS rule (used in TicketView, was undefined)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:06:40 -04:00
jared d7775e62ec Fix layout regressions, nav drawer structure, and security issues
- base.css: add width:100%+min-width:0 to .lt-main so flex column body
  doesn't shrink content due to margin:0 auto from .lt-container
- layout_header.php: restructure mobile nav drawer to match web_template
  exactly (nav-drawer-links nav, direct <a> links, section div, no ul/li
  wrapper, overlay after drawer); fix lt-nav-overlay id mismatch with
  base.js; rename lt-header-username -> lt-header-user (matches CSS);
  add JSON_HEX_TAG to all inline json_encode calls (closes </script> XSS)
- base.css: add lt-kv-row/label/value aliases (display:contents pattern
  used in web_template v1.2 kv-grid); add lt-badge-sm variant
- Admin views: add missing .catch() on editField/editRecurring/loadUsers;
  add JSON_HEX_TAG to json_encode in TemplatesView/WorkflowDesignerView
- TicketView: add JSON_HEX_TAG to all ticket-data json_encode calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 12:43:24 -04:00
jared 51f6991f9d feat: nano-style footer bar, missing utility classes, CSS semantic vars
- layout_footer.php: add lt-footer with context-sensitive keyboard hint bar
  ([ ~ ] HOME | [ / ] SEARCH | [ + ] NEW | [ * ] CFG | [ ? ] HELP)
  Context adapts for dashboard, ticket, and admin pages
- layout_footer.php: wire show-keyboard-help and open-settings for all pages
- base.css: body { display:flex; flex-direction:column } + lt-main { flex:1 }
  so footer sticks to bottom of viewport on short pages
- base.css: add lt-flex-gap-xs/sm/md/lg and lt-flex-align-start/center/end
  (were used across all views but never defined — causing broken layouts)
- base.css: add --lt-danger/amber/cyan/success/text-primary CSS variables
  (referenced in ticket.css and dashboard.css fallbacks but never declared)
- base.css: add lt-text-danger/warning/success/info/primary utility classes
  (used in TicketView, DashboardView, admin views but not defined in base.css)
- DashboardView.php: remove ascii-banner.js (loaded but never called)
- TemplatesView.php: fix priority badge from lt-p* to lt-chip component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:16:05 -04:00
jared 9bdeaf7731 fix: deep audit — wire TDS v1.2 components, fix kanban/tabs/bulk/avatar
- ticket.js: fix showTab() early return preventing attachments/deps from loading
- ticket.js: fix performStatusChange() overwriting lt-status-* classes
- dashboard.js: fix updateSelectionCount() using is-visible instead of style.display
- dashboard.js: fix populateKanbanCards() to use #kanban-col-* IDs (TDS v1.2)
- dashboard.js: fix setViewMode() removing references to old non-TDS elements
- dashboard.js: remove mobile-bottom-nav injection (no CSS existed for it)
- dashboard.css: add full lt-kanban-card component styles with priority accents
- dashboard.css: add mobile sidebar overlay, filter toggle, ticket preview popup CSS
- DashboardView.php: replace priority badges with lt-chip component
- TicketView.php: add lt-avatar with initials to comment author display
- ApiKeysView.php: enhance API usage section with lt-code-block component + curl example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 19:58:14 -04:00
jared 79c2d2b513 feat: complete TDS v1.2 redesign across all views
Full application redesign using Terminal Design System v1.2 (lt-* class
system). Introduces shared layout_header/footer partials, upgrades
base.css/base.js to TDS v1.2, and rewrites all views (Dashboard, Ticket,
CreateTicket, and all 7 admin views) with lt-frame, lt-table, lt-modal,
lt-stats-grid, lt-kv-grid, and data-action event delegation patterns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 19:05:42 -04:00
65 changed files with 13684 additions and 15149 deletions
+11
View File
@@ -26,3 +26,14 @@ ALLOWED_HOSTS=localhost,127.0.0.1
# Timezone (default: America/New_York) # Timezone (default: America/New_York)
TIMEZONE=America/New_York TIMEZONE=America/New_York
# LDAP / lldap (for user avatar lookups)
LDAP_ENABLED=true
LDAP_HOST=10.10.10.39
LDAP_PORT=3890
LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com
LDAP_BIND_PW=
LDAP_BASE_DN=dc=example,dc=com
LDAP_USER_BASE=ou=people,dc=example,dc=com
# How long to cache avatar images locally (seconds, default 3600)
AVATAR_CACHE_TTL=3600
+33 -1
View File
@@ -29,6 +29,8 @@ try {
require_once $auditLogModelPath; require_once $auditLogModelPath;
require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
// Check authentication via session // Check authentication via session
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
@@ -123,6 +125,32 @@ try {
); );
} }
// Matrix notifications
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$commentText = $data['comment_text'] ?? '';
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
if (!empty($mentionedUsers)) {
$mentionedUsernames = array_column($mentionedUsers, 'username');
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
if (!empty($mentionedMatrixIds)) {
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
}
}
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
NotificationHelper::sendCommentNotification($ticketId, $ticketTitle, $commentText, $authorDisplay);
}
// Notify watchers of the new comment
NotificationHelper::notifyWatchers(
$conn, $ticketId, $ticketTitle, 'comment_added',
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
(int)$userId
);
// Add mentioned users to result for frontend // Add mentioned users to result for frontend
$result['mentions'] = array_map(function($u) { $result['mentions'] = array_map(function($u) {
return $u['username']; return $u['username'];
@@ -138,9 +166,12 @@ try {
ob_end_clean(); ob_end_clean();
// Return JSON response // Return JSON response
if ($result['success']) {
http_response_code(201);
}
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode($result); echo json_encode($result);
} catch (Exception $e) { } catch (Exception $e) {
// Discard any unexpected output // Discard any unexpected output
ob_end_clean(); ob_end_clean();
@@ -149,6 +180,7 @@ try {
error_log("Add comment API error: " . $e->getMessage()); error_log("Add comment API error: " . $e->getMessage());
// Return error response // Return error response
http_response_code(500);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode([ echo json_encode([
'success' => false, 'success' => false,
+23 -5
View File
@@ -3,6 +3,8 @@ require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php'; require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.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);
@@ -12,10 +14,10 @@ if (!is_array($data)) {
exit; exit;
} }
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : null; $ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0;
$assignedTo = $data['assigned_to'] ?? null; $assignedTo = $data['assigned_to'] ?? null;
if (!$ticketId) { if ($ticketId <= 0) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Ticket ID required']); echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
exit; exit;
@@ -34,7 +36,7 @@ if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
} }
// Authorization: only admins or the ticket creator/assignee can reassign // Authorization: only admins or the ticket creator/assignee can reassign
if (!$isAdmin && $ticket['created_by'] !== $userId && $ticket['assigned_to'] !== $userId) { if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
http_response_code(403); http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Permission denied']); echo json_encode(['success' => false, 'error' => 'Permission denied']);
exit; exit;
@@ -51,6 +53,7 @@ if ($assignedTo === null || $assignedTo === '') {
$assignedTo = (int)$assignedTo; $assignedTo = (int)$assignedTo;
$targetUser = $userModel->getUserById($assignedTo); $targetUser = $userModel->getUserById($assignedTo);
if (!$targetUser) { if (!$targetUser) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid user ID']); echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
exit; exit;
} }
@@ -59,12 +62,27 @@ if ($assignedTo === null || $assignedTo === '') {
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId); $success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
if ($success) { if ($success) {
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]); $auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
$assigneeMatrix = isset($targetUser['username'])
? SynapseHelper::resolveUsername($targetUser['username'])
: null;
NotificationHelper::sendAssignmentNotification(
$ticketId,
$ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName,
$assigneeMatrix,
$changedByDisplay
);
}
} }
} }
if (!$success) { if (!$success) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to update ticket assignment']); apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']);
} else { } else {
echo json_encode(['success' => true]); apiRespond(['success' => true]);
} }
+2 -2
View File
@@ -71,8 +71,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// Normal JSON response for filtered logs // Normal JSON response for filtered logs
try { try {
// Get pagination parameters // Get pagination parameters
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1; $page = max(1, (int)($_GET['page'] ?? 1));
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 50; $limit = min(500, max(1, (int)($_GET['limit'] ?? 50)));
$offset = ($page - 1) * $limit; $offset = ($page - 1) * $limit;
// Build filters // Build filters
+14
View File
@@ -38,6 +38,8 @@ if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) {
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit; exit;
} }
// Rotate token after successful validation; endpoints include it in their JSON response
$GLOBALS['_new_csrf_token'] = CsrfMiddleware::rotateToken();
} }
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -47,3 +49,15 @@ $currentUser = $_SESSION['user'];
$userId = $currentUser['user_id']; $userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false; $isAdmin = $currentUser['is_admin'] ?? false;
$conn = Database::getConnection(); $conn = Database::getConnection();
/**
* Output a JSON response, appending the rotated CSRF token so the
* client-side lt.api interceptor can update window.CSRF_TOKEN.
*/
function apiRespond(array $data): void {
if (!empty($GLOBALS['_new_csrf_token'])) {
$data['csrf_token'] = $GLOBALS['_new_csrf_token'];
}
echo json_encode($data);
exit;
}
+8 -6
View File
@@ -50,12 +50,14 @@ if (!$operationType || empty($ticketIds)) {
exit; exit;
} }
// Validate ticket IDs are integers // Validate ticket IDs are positive integers
foreach ($ticketIds as $ticketId) { $ticketIds = array_values(array_filter(array_map(function($id) {
if (!is_numeric($ticketId)) { $int = (int)$id;
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID format']); return ($int > 0 && (string)$int === (string)$id) ? $int : null;
exit; }, $ticketIds)));
} if (empty($ticketIds)) {
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
exit;
} }
// Use centralized database connection // Use centralized database connection
+2 -1
View File
@@ -7,6 +7,8 @@
ini_set('display_errors', 0); ini_set('display_errors', 0);
error_reporting(E_ALL); error_reporting(E_ALL);
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php'; require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api'); RateLimitMiddleware::apply('api');
@@ -109,7 +111,6 @@ try {
$dependencyModel = new DependencyModel($conn); $dependencyModel = new DependencyModel($conn);
$dependencyModel->addDependency($result['ticket_id'], $sourceTicketId, 'relates_to', $userId); $dependencyModel->addDependency($result['ticket_id'], $sourceTicketId, 'relates_to', $userId);
header('Content-Type: application/json');
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'new_ticket_id' => $result['ticket_id'], 'new_ticket_id' => $result['ticket_id'],
+12
View File
@@ -66,23 +66,35 @@ try {
case 'POST': case 'POST':
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
exit;
}
$result = $model->createDefinition($data); $result = $model->createDefinition($data);
echo json_encode($result); echo json_encode($result);
break; break;
case 'PUT': case 'PUT':
if (!$id) { if (!$id) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ID required']); echo json_encode(['success' => false, 'error' => 'ID required']);
exit; exit;
} }
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
exit;
}
$result = $model->updateDefinition($id, $data); $result = $model->updateDefinition($id, $data);
echo json_encode($result); echo json_encode($result);
break; break;
case 'DELETE': case 'DELETE':
if (!$id) { if (!$id) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ID required']); echo json_encode(['success' => false, 'error' => 'ID required']);
exit; exit;
} }
+12 -9
View File
@@ -51,13 +51,11 @@ if (!CsrfMiddleware::validateToken($csrfToken)) {
} }
// Get attachment ID // Get attachment ID
$attachmentId = $input['attachment_id'] ?? null; $attachmentId = isset($input['attachment_id']) ? (int)$input['attachment_id'] : 0;
if (!$attachmentId || !is_numeric($attachmentId)) { if ($attachmentId <= 0 || (string)$attachmentId !== (string)($input['attachment_id'] ?? '')) {
ResponseHelper::error('Valid attachment ID is required'); ResponseHelper::error('Valid attachment ID is required');
} }
$attachmentId = (int)$attachmentId;
try { try {
$attachmentModel = new AttachmentModel(Database::getConnection()); $attachmentModel = new AttachmentModel(Database::getConnection());
@@ -80,12 +78,17 @@ try {
ResponseHelper::forbidden('You do not have permission to delete this attachment'); ResponseHelper::forbidden('You do not have permission to delete this attachment');
} }
// Delete the file // Delete the file — use realpath() to prevent path traversal
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads'; $uploadDir = realpath($GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads');
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename']; $filePath = $uploadDir . '/' . (int)$attachment['ticket_id'] . '/' . $attachment['filename'];
$realPath = realpath($filePath);
if (file_exists($filePath)) { if ($realPath !== false) {
if (!unlink($filePath)) { // Ensure the resolved path is still inside the upload directory
if (strncmp($realPath, $uploadDir . DIRECTORY_SEPARATOR, strlen($uploadDir) + 1) !== 0) {
ResponseHelper::forbidden('Access denied');
}
if (!unlink($realPath)) {
ResponseHelper::serverError('Failed to delete file'); ResponseHelper::serverError('Failed to delete file');
} }
} }
+1
View File
@@ -115,6 +115,7 @@ try {
} catch (Exception $e) { } catch (Exception $e) {
ob_end_clean(); ob_end_clean();
error_log("Delete comment API error: " . $e->getMessage()); error_log("Delete comment API error: " . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode([ echo json_encode([
'success' => false, 'success' => false,
+2 -4
View File
@@ -22,16 +22,14 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
} }
// Get attachment ID // Get attachment ID
$attachmentId = $_GET['id'] ?? null; $attachmentId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if (!$attachmentId || !is_numeric($attachmentId)) { if ($attachmentId <= 0 || (string)$attachmentId !== (string)($_GET['id'] ?? '')) {
http_response_code(400); http_response_code(400);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Valid attachment ID is required']); echo json_encode(['success' => false, 'error' => 'Valid attachment ID is required']);
exit; exit;
} }
$attachmentId = (int)$attachmentId;
try { try {
$attachmentModel = new AttachmentModel(Database::getConnection()); $attachmentModel = new AttachmentModel(Database::getConnection());
+82 -3
View File
@@ -19,9 +19,11 @@ try {
require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session // Check authentication via session
session_start(); if (session_status() === PHP_SESSION_NONE) { session_start(); }
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
header('Content-Type: application/json'); header('Content-Type: application/json');
http_response_code(401); http_response_code(401);
@@ -39,8 +41,9 @@ try {
$category = isset($_GET['category']) ? $_GET['category'] : null; $category = isset($_GET['category']) ? $_GET['category'] : null;
$type = isset($_GET['type']) ? $_GET['type'] : null; $type = isset($_GET['type']) ? $_GET['type'] : null;
$search = isset($_GET['search']) ? trim($_GET['search']) : null; $search = isset($_GET['search']) ? trim($_GET['search']) : null;
$format = isset($_GET['format']) ? $_GET['format'] : 'csv'; $format = isset($_GET['format']) ? $_GET['format'] : 'csv';
$ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null; $ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null;
$singleId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : null;
// Initialize model // Initialize model
$ticketModel = new TicketModel($conn); $ticketModel = new TicketModel($conn);
@@ -149,10 +152,86 @@ try {
], JSON_PRETTY_PRINT); ], JSON_PRETTY_PRINT);
exit; exit;
} elseif ($format === 'full') {
// Full single-ticket export: ticket + all comments + audit timeline
if (!$singleId) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'format=full requires a ticket_id parameter']);
exit;
}
$ticket = $ticketModel->getTicketById($singleId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
header('Content-Type: application/json');
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$commentModel = new CommentModel($conn);
$auditLogModel = new AuditLogModel($conn);
// Load flat comment list (no threading nesting in export)
$rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false);
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id']);
$comments = array_map(function($c) {
return [
'comment_id' => $c['comment_id'],
'author' => $c['display_name'] ?? $c['username'] ?? 'Unknown',
'created_at' => $c['created_at'],
'updated_at' => $c['updated_at'] ?? null,
'comment_text' => $c['comment_text'],
'parent_comment_id' => $c['parent_comment_id'] ?? null,
];
}, $rawComments);
$timelineOut = array_map(function($row) {
$details = $row['details'];
if (is_string($details)) {
$details = json_decode($details, true) ?? $details;
}
return [
'action' => $row['action_type'],
'entity' => $row['entity_type'],
'actor' => $row['display_name'] ?? $row['username'] ?? 'System',
'details' => $details,
'created_at' => $row['created_at'],
];
}, $timeline);
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="ticket_' . $ticket['ticket_id'] . '_full_' . date('Y-m-d_His') . '.json"');
header('Cache-Control: no-cache, must-revalidate');
echo json_encode([
'exported_at' => date('c'),
'ticket' => [
'ticket_id' => $ticket['ticket_id'],
'title' => $ticket['title'],
'status' => $ticket['status'],
'priority' => 'P' . $ticket['priority'],
'category' => $ticket['category'],
'type' => $ticket['type'],
'visibility' => $ticket['visibility'] ?? 'public',
'description' => $ticket['description'],
'created_by' => $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System',
'assigned_to' => $ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned',
'created_at' => $ticket['created_at'],
'updated_at' => $ticket['updated_at'],
'closed_at' => $ticket['closed_at'] ?? null,
],
'comments' => $comments,
'comment_count' => count($comments),
'timeline' => $timelineOut,
], JSON_PRETTY_PRINT);
exit;
} else { } else {
header('Content-Type: application/json'); header('Content-Type: application/json');
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv or json.']); echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv, json, or full.']);
exit; exit;
} }
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* Get Comments API
* Returns paginated comments for a ticket (used by "Load more" on ticket view)
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
$ticketId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : 0;
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 50;
if ($ticketId <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid ticket_id']);
exit;
}
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$commentModel = new CommentModel($conn);
$total = $commentModel->getCommentCount($ticketId);
$comments = $commentModel->getCommentsByTicketId($ticketId, true, $limit, $offset);
echo json_encode([
'success' => true,
'comments' => $comments,
'total' => $total,
'offset' => $offset,
'limit' => $limit,
'has_more' => ($offset + $limit) < $total,
]);
+2 -5
View File
@@ -24,18 +24,15 @@ try {
} }
// Get template ID from query parameter // Get template ID from query parameter
$templateId = $_GET['template_id'] ?? null; $templateId = isset($_GET['template_id']) ? (int)$_GET['template_id'] : 0;
if (!$templateId || !is_numeric($templateId)) { if ($templateId <= 0 || (string)$templateId !== (string)($_GET['template_id'] ?? '')) {
ErrorHandler::sendValidationError( ErrorHandler::sendValidationError(
['template_id' => 'Valid template ID required'], ['template_id' => 'Valid template ID required'],
'Invalid request' 'Invalid request'
); );
} }
// Cast to integer for safety
$templateId = (int)$templateId;
// Get template // Get template
$conn = Database::getConnection(); $conn = Database::getConnection();
$templateModel = new TemplateModel($conn); $templateModel = new TemplateModel($conn);
+8
View File
@@ -70,6 +70,10 @@ try {
echo json_encode($result); echo json_encode($result);
} else { } else {
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data) || empty($data['schedule_type']) || empty($data['title_template'])) {
echo json_encode(['success' => false, 'error' => 'schedule_type and title_template are required']);
exit;
}
// Calculate next run time // Calculate next run time
$nextRun = calculateNextRun( $nextRun = calculateNextRun(
@@ -94,6 +98,10 @@ try {
} }
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data) || empty($data['schedule_type'])) {
echo json_encode(['success' => false, 'error' => 'Invalid request data']);
exit;
}
// Recalculate next run time if schedule changed // Recalculate next run time if schedule changed
$nextRun = calculateNextRun( $nextRun = calculateNextRun(
+52 -14
View File
@@ -73,17 +73,36 @@ try {
case 'POST': case 'POST':
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
// Validate required fields and lengths
$templateName = trim($data['template_name'] ?? '');
$titleTemplate = trim($data['title_template'] ?? '');
if (!$templateName || mb_strlen($templateName) > 100) {
echo json_encode(['success' => false, 'error' => 'Template name is required (max 100 chars)']);
exit;
}
if (!$titleTemplate || mb_strlen($titleTemplate) > 255) {
echo json_encode(['success' => false, 'error' => 'Title template is required (max 255 chars)']);
exit;
}
$allowedCategories = ['General','Hardware','Software','Network','Security'];
$allowedTypes = ['Issue','Maintenance','Install','Task','Upgrade','Problem'];
$category = in_array($data['category'] ?? '', $allowedCategories) ? $data['category'] : 'General';
$type = in_array($data['type'] ?? '', $allowedTypes) ? $data['type'] : 'Issue';
$priority = max(1, min(5, (int)($data['default_priority'] ?? 4)));
$isActive = $data['is_active'] ? 1 : 0;
$description = mb_substr($data['description_template'] ?? '', 0, 65535);
$stmt = $conn->prepare("INSERT INTO ticket_templates $stmt = $conn->prepare("INSERT INTO ticket_templates
(template_name, title_template, description_template, category, type, default_priority, is_active) (template_name, title_template, description_template, category, type, default_priority, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?)"); VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->bind_param('sssssii', $stmt->bind_param('sssssii',
$data['template_name'], $templateName,
$data['title_template'], $titleTemplate,
$data['description_template'], $description,
$data['category'], $category,
$data['type'], $type,
$data['default_priority'] ?? 4, $priority,
$data['is_active'] ?? 1 $isActive
); );
if ($stmt->execute()) { if ($stmt->execute()) {
@@ -103,18 +122,37 @@ try {
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
// Validate required fields and lengths
$templateName = trim($data['template_name'] ?? '');
$titleTemplate = trim($data['title_template'] ?? '');
if (!$templateName || mb_strlen($templateName) > 100) {
echo json_encode(['success' => false, 'error' => 'Template name is required (max 100 chars)']);
exit;
}
if (!$titleTemplate || mb_strlen($titleTemplate) > 255) {
echo json_encode(['success' => false, 'error' => 'Title template is required (max 255 chars)']);
exit;
}
$allowedCategories = ['General','Hardware','Software','Network','Security'];
$allowedTypes = ['Issue','Maintenance','Install','Task','Upgrade','Problem'];
$category = in_array($data['category'] ?? '', $allowedCategories) ? $data['category'] : 'General';
$type = in_array($data['type'] ?? '', $allowedTypes) ? $data['type'] : 'Issue';
$priority = max(1, min(5, (int)($data['default_priority'] ?? 4)));
$isActive = $data['is_active'] ? 1 : 0;
$description = mb_substr($data['description_template'] ?? '', 0, 65535);
$stmt = $conn->prepare("UPDATE ticket_templates SET $stmt = $conn->prepare("UPDATE ticket_templates SET
template_name = ?, title_template = ?, description_template = ?, template_name = ?, title_template = ?, description_template = ?,
category = ?, type = ?, default_priority = ?, is_active = ? category = ?, type = ?, default_priority = ?, is_active = ?
WHERE template_id = ?"); WHERE template_id = ?");
$stmt->bind_param('sssssiii', $stmt->bind_param('sssssiii',
$data['template_name'], $templateName,
$data['title_template'], $titleTemplate,
$data['description_template'], $description,
$data['category'], $category,
$data['type'], $type,
$data['default_priority'] ?? 4, $priority,
$data['is_active'] ?? 1, $isActive,
$id $id
); );
+1 -1
View File
@@ -120,7 +120,7 @@ try {
$stmt = $conn->prepare("UPDATE status_transitions SET $stmt = $conn->prepare("UPDATE status_transitions SET
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ? from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
WHERE transition_id = ?"); WHERE transition_id = ?");
$stmt->bind_param('ssiiii', $stmt->bind_param('ssiiiii',
$data['from_status'], $data['from_status'],
$data['to_status'], $data['to_status'],
$data['requires_comment'] ?? 0, $data['requires_comment'] ?? 0,
+19 -19
View File
@@ -17,23 +17,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$filter = $filtersModel->getFilter($filterId, $userId); $filter = $filtersModel->getFilter($filterId, $userId);
if ($filter) { if ($filter) {
echo json_encode(['success' => true, 'filter' => $filter]); apiRespond(['success' => true, 'filter' => $filter]);
} else { } else {
http_response_code(404); http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Filter not found']); apiRespond(['success' => false, 'error' => 'Filter not found']);
} }
} else if (isset($_GET['default'])) { } else if (isset($_GET['default'])) {
// Get default filter // Get default filter
$filter = $filtersModel->getDefaultFilter($userId); $filter = $filtersModel->getDefaultFilter($userId);
echo json_encode(['success' => true, 'filter' => $filter]); apiRespond(['success' => true, 'filter' => $filter]);
} else { } else {
// Get all filters // Get all filters
$filters = $filtersModel->getUserFilters($userId); $filters = $filtersModel->getUserFilters($userId);
echo json_encode(['success' => true, 'filters' => $filters]); apiRespond(['success' => true, 'filters' => $filters]);
} }
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']); apiRespond(['success' => false, 'error' => 'Failed to fetch filters']);
} }
exit; exit;
} }
@@ -44,7 +44,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) { if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); apiRespond(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
exit; exit;
} }
@@ -55,16 +55,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate filter name // Validate filter name
if (empty($filterName) || strlen($filterName) > 100) { if (empty($filterName) || strlen($filterName) > 100) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid filter name']); apiRespond(['success' => false, 'error' => 'Invalid filter name']);
exit; exit;
} }
try { try {
$result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault); $result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault);
echo json_encode($result); apiRespond($result);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save filter']); apiRespond(['success' => false, 'error' => 'Failed to save filter']);
} }
exit; exit;
} }
@@ -75,7 +75,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
if (!isset($data['filter_id'])) { if (!isset($data['filter_id'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_id']); apiRespond(['success' => false, 'error' => 'Missing filter_id']);
exit; exit;
} }
@@ -85,10 +85,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
if (isset($data['set_default']) && $data['set_default'] === true) { if (isset($data['set_default']) && $data['set_default'] === true) {
try { try {
$result = $filtersModel->setDefaultFilter($filterId, $userId); $result = $filtersModel->setDefaultFilter($filterId, $userId);
echo json_encode($result); apiRespond($result);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to set default filter']); apiRespond(['success' => false, 'error' => 'Failed to set default filter']);
} }
exit; exit;
} }
@@ -96,7 +96,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
// Handle full filter update // Handle full filter update
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) { if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); apiRespond(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
exit; exit;
} }
@@ -106,10 +106,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
try { try {
$result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault); $result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault);
echo json_encode($result); apiRespond($result);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to update filter']); apiRespond(['success' => false, 'error' => 'Failed to update filter']);
} }
exit; exit;
} }
@@ -120,7 +120,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
if (!isset($data['filter_id'])) { if (!isset($data['filter_id'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_id']); apiRespond(['success' => false, 'error' => 'Missing filter_id']);
exit; exit;
} }
@@ -128,14 +128,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
try { try {
$result = $filtersModel->deleteFilter($filterId, $userId); $result = $filtersModel->deleteFilter($filterId, $userId);
echo json_encode($result); apiRespond($result);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to delete filter']); apiRespond(['success' => false, 'error' => 'Failed to delete filter']);
} }
exit; exit;
} }
// Method not allowed // Method not allowed
http_response_code(405); http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']); apiRespond(['success' => false, 'error' => 'Method not allowed']);
+47
View File
@@ -143,6 +143,10 @@ switch ($method) {
// Add a new dependency // Add a new dependency
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
ResponseHelper::error('Invalid JSON');
}
$ticketId = $data['ticket_id'] ?? null; $ticketId = $data['ticket_id'] ?? null;
$dependsOnId = $data['depends_on_id'] ?? null; $dependsOnId = $data['depends_on_id'] ?? null;
$type = $data['dependency_type'] ?? 'blocks'; $type = $data['dependency_type'] ?? 'blocks';
@@ -151,6 +155,16 @@ switch ($method) {
ResponseHelper::error('Both ticket_id and depends_on_id are required'); ResponseHelper::error('Both ticket_id and depends_on_id are required');
} }
// Verify user can access both tickets before creating dependency
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
$tgtTicket = $ticketModel->getTicketById((int)$dependsOnId);
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
ResponseHelper::notFound('Target ticket not found');
}
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId); $result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
if ($result['success']) { if ($result['success']) {
@@ -171,6 +185,10 @@ switch ($method) {
// Remove a dependency // Remove a dependency
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
ResponseHelper::error('Invalid JSON');
}
$dependencyId = $data['dependency_id'] ?? null; $dependencyId = $data['dependency_id'] ?? null;
// Alternative: delete by ticket IDs // Alternative: delete by ticket IDs
@@ -179,6 +197,18 @@ switch ($method) {
$dependsOnId = $data['depends_on_id']; $dependsOnId = $data['depends_on_id'];
$type = $data['dependency_type'] ?? 'blocks'; $type = $data['dependency_type'] ?? 'blocks';
// Validate dependency type
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
if (!in_array($type, $validTypes, true)) {
ResponseHelper::error('Invalid dependency type');
}
// Verify user can access the source ticket
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type); $result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
if ($result) { if ($result) {
@@ -192,6 +222,23 @@ switch ($method) {
ResponseHelper::error('Failed to remove dependency'); ResponseHelper::error('Failed to remove dependency');
} }
} elseif ($dependencyId) { } elseif ($dependencyId) {
// Look up dependency to verify ticket access before deletion
$depLookupSql = "SELECT ticket_id FROM ticket_dependencies WHERE dependency_id = ?";
$depLookupStmt = $conn->prepare($depLookupSql);
$depLookupStmt->bind_param("i", $dependencyId);
$depLookupStmt->execute();
$depRow = $depLookupStmt->get_result()->fetch_assoc();
$depLookupStmt->close();
if (!$depRow) {
ResponseHelper::notFound('Dependency not found');
}
$depTicket = $ticketModel->getTicketById((int)$depRow['ticket_id']);
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
ResponseHelper::forbidden('Access denied');
}
$result = $dependencyModel->removeDependency($dependencyId); $result = $dependencyModel->removeDependency($dependencyId);
if ($result) { if ($result) {
+1
View File
@@ -104,6 +104,7 @@ try {
} catch (Exception $e) { } catch (Exception $e) {
ob_end_clean(); ob_end_clean();
error_log("Update comment API error: " . $e->getMessage()); error_log("Update comment API error: " . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode([ echo json_encode([
'success' => false, 'success' => false,
+55 -9
View File
@@ -26,6 +26,7 @@ try {
require_once $commentModelPath; require_once $commentModelPath;
require_once $auditLogModelPath; require_once $auditLogModelPath;
require_once $workflowModelPath; require_once $workflowModelPath;
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
// Check authentication via session // Check authentication via session
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
@@ -53,6 +54,7 @@ try {
// Updated controller class that handles partial updates // Updated controller class that handles partial updates
class ApiTicketController { class ApiTicketController {
private $conn;
private $ticketModel; private $ticketModel;
private $commentModel; private $commentModel;
private $auditLog; private $auditLog;
@@ -62,6 +64,7 @@ try {
private $currentUser; private $currentUser;
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = []) { public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = []) {
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn); $this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn); $this->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn); $this->auditLog = new AuditLogModel($conn);
@@ -92,8 +95,8 @@ try {
// Authorization: admins can edit any ticket; others only their own or assigned // Authorization: admins can edit any ticket; others only their own or assigned
if (!$this->isAdmin if (!$this->isAdmin
&& $currentTicket['created_by'] != $this->userId && (int)$currentTicket['created_by'] !== (int)$this->userId
&& $currentTicket['assigned_to'] != $this->userId && (int)$currentTicket['assigned_to'] !== (int)$this->userId
) { ) {
return [ return [
'success' => false, 'success' => false,
@@ -177,19 +180,62 @@ try {
]; ];
} }
$this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId); $visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if ($visResult && $this->userId) {
$this->auditLog->log(
$this->userId, 'update', 'ticket', (string)$id,
[
'field' => 'visibility',
'from' => $currentTicket['visibility'] ?? 'public',
'to' => $data['visibility'],
'groups' => $visibilityGroups
]
);
}
} }
// Log ticket update to audit log // Log ticket update to audit log — only the changed fields (delta)
if ($this->userId) { if ($this->userId) {
$this->auditLog->logTicketUpdate($this->userId, $id, $data); $trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
$delta = [];
foreach ($trackFields as $field) {
$oldVal = (string)($currentTicket[$field] ?? '');
$newVal = (string)($updateData[$field] ?? '');
if ($oldVal !== $newVal) {
$delta[$field] = ['from' => $oldVal, 'to' => $newVal];
}
}
if (!empty($delta)) {
$this->auditLog->logTicketUpdate($this->userId, $id, $delta);
}
}
// Notify on status change (global notify list + watchers)
if ($currentTicket['status'] !== $updateData['status']) {
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
NotificationHelper::sendStatusChangeNotification(
$id,
$currentTicket['status'],
$updateData['status'],
$updateData['title'],
$changedBy
);
NotificationHelper::notifyWatchers(
$this->conn,
$id,
$updateData['title'],
'status_changed',
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
(int)$this->userId
);
} }
return [ return [
'success' => true, 'success' => true,
'status' => $updateData['status'], 'status' => $updateData['status'],
'priority' => $updateData['priority'], 'priority' => $updateData['priority'],
'message' => 'Ticket updated successfully' 'updated_at' => date('Y-m-d H:i:s'),
'message' => 'Ticket updated successfully'
]; ];
} }
} }
+20 -4
View File
@@ -151,10 +151,26 @@ if (!is_dir($ticketDir)) {
} }
} }
// Generate unique filename // Derive extension from validated MIME type (never from user-supplied filename)
$extension = pathinfo($file['name'], PATHINFO_EXTENSION); // This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
$safeExtension = preg_replace('/[^a-zA-Z0-9]/', '', $extension); $mimeToExt = [
$uniqueFilename = uniqid('att_', true) . ($safeExtension ? '.' . $safeExtension : ''); 'image/jpeg' => 'jpg', 'image/png' => 'png',
'image/gif' => 'gif', 'image/webp' => 'webp',
'application/pdf' => 'pdf',
'text/plain' => 'txt', 'text/csv' => 'csv',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/zip' => 'zip',
'application/x-7z-compressed' => '7z',
'application/x-tar' => 'tar',
'application/gzip' => 'gz',
'application/json' => 'json',
'application/xml' => 'xml',
];
$safeExtension = $mimeToExt[$mimeType] ?? 'bin';
$uniqueFilename = uniqid('att_', true) . '.' . $safeExtension;
$targetPath = $ticketDir . '/' . $uniqueFilename; $targetPath = $ticketDir . '/' . $uniqueFilename;
// Move uploaded file // Move uploaded file
+168
View File
@@ -0,0 +1,168 @@
<?php
/**
* User Avatar API
*
* Serves profile pictures fetched from lldap via LDAP.
* Caches images locally to avoid repeated LDAP queries.
*
* GET /api/user_avatar.php?user_id=123
* Returns the user's JPEG avatar (from cache or LDAP).
* Returns 404 if the user has no avatar set in lldap.
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Must be authenticated
if (!isset($_SESSION['user']['user_id'])) {
http_response_code(401);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
exit;
}
$cfg = $GLOBALS['config'];
// Validate user_id parameter
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : 0;
if ($userId <= 0) {
http_response_code(400);
exit;
}
// Ensure LDAP is enabled and extension is loaded
if (!$cfg['LDAP_ENABLED'] || !extension_loaded('ldap')) {
http_response_code(404);
exit;
}
// Ensure avatar cache directory exists
$cacheDir = rtrim($cfg['AVATAR_CACHE_DIR'], '/');
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
$cacheFile = $cacheDir . '/user_' . $userId . '.jpg';
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
// Serve from cache if fresh
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
header('Content-Type: image/jpeg');
header('Cache-Control: private, max-age=' . $cacheTtl);
header('X-Avatar-Source: cache');
readfile($cacheFile);
exit;
}
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
$noAvatarSentinel = $cacheDir . '/user_' . $userId . '.none';
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
http_response_code(404);
exit;
}
// Look up username from DB
try {
$conn = Database::getConnection();
$stmt = $conn->prepare("SELECT username FROM users WHERE user_id = ? LIMIT 1");
$stmt->bind_param('i', $userId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
if (!$row || empty($row['username'])) {
http_response_code(404);
exit;
}
$username = $row['username'];
} catch (Exception $e) {
error_log("user_avatar: DB error for user_id=$userId: " . $e->getMessage());
http_response_code(500);
exit;
}
// Query lldap via LDAP
$ldapHost = $cfg['LDAP_HOST'];
$ldapPort = $cfg['LDAP_PORT'];
$bindDn = $cfg['LDAP_BIND_DN'];
$bindPw = $cfg['LDAP_BIND_PW'];
$userBase = $cfg['LDAP_USER_BASE'];
// Escape username for LDAP filter (RFC 4515)
$safeUsername = ldap_escape($username, '', LDAP_ESCAPE_FILTER);
$filter = "(uid=$safeUsername)";
$avatarData = null;
try {
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort");
if (!$ldap) {
throw new RuntimeException("ldap_connect failed");
}
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);
ldap_set_option($ldap, LDAP_OPT_NETWORK_TIMEOUT, 3);
ldap_set_option($ldap, LDAP_OPT_TIMELIMIT, 3);
if (!@ldap_bind($ldap, $bindDn, $bindPw)) {
throw new RuntimeException("LDAP bind failed: " . ldap_error($ldap));
}
$search = @ldap_search($ldap, $userBase, $filter, ['avatar'], 0, 1, 3);
if (!$search) {
throw new RuntimeException("LDAP search failed: " . ldap_error($ldap));
}
$entries = ldap_get_entries($ldap, $search);
if ($entries['count'] > 0 && !empty($entries[0]['avatar'][0])) {
// ldap_get_entries() returns the attribute value as raw binary.
$avatarData = $entries[0]['avatar'][0];
}
ldap_unbind($ldap);
} catch (Exception $e) {
error_log("user_avatar: LDAP error for username=$username: " . $e->getMessage());
// Fall through to 404
}
if ($avatarData === null || strlen($avatarData) < 100) {
// Write sentinel so we don't hammer LDAP for users without avatars
file_put_contents($noAvatarSentinel, '');
http_response_code(404);
exit;
}
// Validate it's actually a JPEG (magic bytes FF D8 FF)
if (substr($avatarData, 0, 3) !== "\xFF\xD8\xFF") {
error_log("user_avatar: non-JPEG data for username=$username");
file_put_contents($noAvatarSentinel, '');
http_response_code(404);
exit;
}
// Cache to disk
file_put_contents($cacheFile, $avatarData);
// Remove stale sentinel if present
if (file_exists($noAvatarSentinel)) {
unlink($noAvatarSentinel);
}
header('Content-Type: image/jpeg');
header('Cache-Control: private, max-age=' . $cacheTtl);
header('X-Avatar-Source: ldap');
echo $avatarData;
+13 -13
View File
@@ -13,10 +13,10 @@ $prefsModel = new UserPreferencesModel($conn);
if ($_SERVER['REQUEST_METHOD'] === 'GET') { if ($_SERVER['REQUEST_METHOD'] === 'GET') {
try { try {
$prefs = $prefsModel->getUserPreferences($userId); $prefs = $prefsModel->getUserPreferences($userId);
echo json_encode(['success' => true, 'preferences' => $prefs]); apiRespond(['success' => true, 'preferences' => $prefs]);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch preferences']); apiRespond(['success' => false, 'error' => 'Failed to fetch preferences']);
} }
exit; exit;
} }
@@ -43,13 +43,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!in_array($key, $validKeys)) continue; if (!in_array($key, $validKeys)) continue;
$prefsModel->setPreference($userId, $key, $value); $prefsModel->setPreference($userId, $key, $value);
if ($key === 'rows_per_page') { if ($key === 'rows_per_page') {
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/'); setcookie('ticketsPerPage', $value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
} }
} }
echo json_encode(['success' => true]); apiRespond(['success' => true]);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save preferences']); apiRespond(['success' => false, 'error' => 'Failed to save preferences']);
} }
exit; exit;
} }
@@ -57,7 +57,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Single preference: { key, value } // Single preference: { key, value }
if (!isset($data['key']) || !isset($data['value'])) { if (!isset($data['key']) || !isset($data['value'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing key or value']); apiRespond(['success' => false, 'error' => 'Missing key or value']);
exit; exit;
} }
@@ -66,7 +66,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!in_array($key, $validKeys)) { if (!in_array($key, $validKeys)) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid preference key']); apiRespond(['success' => false, 'error' => 'Invalid preference key']);
exit; exit;
} }
@@ -78,10 +78,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/'); setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
} }
echo json_encode(['success' => $success]); apiRespond(['success' => $success]);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save preference']); apiRespond(['success' => false, 'error' => 'Failed to save preference']);
} }
exit; exit;
} }
@@ -92,20 +92,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
if (!isset($data['key'])) { if (!isset($data['key'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing key']); apiRespond(['success' => false, 'error' => 'Missing key']);
exit; exit;
} }
try { try {
$success = $prefsModel->deletePreference($userId, $data['key']); $success = $prefsModel->deletePreference($userId, $data['key']);
echo json_encode(['success' => $success]); apiRespond(['success' => $success]);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to delete preference']); apiRespond(['success' => false, 'error' => 'Failed to delete preference']);
} }
exit; exit;
} }
// Method not allowed // Method not allowed
http_response_code(405); http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']); apiRespond(['success' => false, 'error' => 'Method not allowed']);
+99
View File
@@ -0,0 +1,99 @@
<?php
/**
* Watch / Unwatch Ticket API
*
* GET ?ticket_id=N → returns { watching: bool, watcher_count: int }
* POST { ticket_id, action: 'watch'|'unwatch' } → toggles watcher row
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
$ticketId = isset($_GET['ticket_id'])
? (int)$_GET['ticket_id']
: (isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$ticketId = (int)($data['ticket_id'] ?? 0);
$action = $data['action'] ?? '';
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
exit;
}
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
if ($action === 'watch') {
$stmt = $conn->prepare(
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
);
$stmt->bind_param("ii", $ticketId, $userId);
$stmt->execute();
$stmt->close();
} else {
$stmt = $conn->prepare(
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
);
$stmt->bind_param("ii", $ticketId, $userId);
$stmt->execute();
$stmt->close();
}
// Return updated state
$countStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
);
$countStmt->bind_param("i", $ticketId);
$countStmt->execute();
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
$countStmt->close();
apiRespond([
'success' => true,
'watching' => $action === 'watch',
'watcher_count' => $count,
]);
}
// GET — return current watch state for this user
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
if ($ticketId <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
exit;
}
$watchingStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
);
$watchingStmt->bind_param("ii", $ticketId, $userId);
$watchingStmt->execute();
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
$watchingStmt->close();
$countStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
);
$countStmt->bind_param("i", $ticketId);
$countStmt->execute();
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
$countStmt->close();
echo json_encode([
'success' => true,
'watching' => $watching,
'watcher_count' => $count,
]);
+4954 -1068
View File
File diff suppressed because it is too large Load Diff
+304 -6167
View File
File diff suppressed because it is too large Load Diff
+257 -2731
View File
File diff suppressed because it is too large Load Diff
+2546 -392
View File
File diff suppressed because it is too large Load Diff
+200 -458
View File
@@ -72,31 +72,6 @@ function initMobileSidebar() {
sidebar.insertBefore(closeBtn, sidebar.firstChild); sidebar.insertBefore(closeBtn, sidebar.firstChild);
} }
// Create mobile bottom navigation
if (!document.getElementById('mobileBottomNav')) {
const nav = document.createElement('nav');
nav.id = 'mobileBottomNav';
nav.className = 'mobile-bottom-nav';
nav.innerHTML = `
<a href="/">
<span class="nav-icon">[ ~ ]</span>
<span class="nav-label">HOME</span>
</a>
<button type="button" data-action="open-mobile-sidebar">
<span class="nav-icon">[ / ]</span>
<span class="nav-label">FILTER</span>
</button>
<a href="/ticket/create">
<span class="nav-icon">[ + ]</span>
<span class="nav-label">NEW</span>
</a>
<button type="button" data-action="open-settings-modal">
<span class="nav-icon">[ * ]</span>
<span class="nav-label">CFG</span>
</button>
`;
document.body.appendChild(nav);
}
} }
// Restore sidebar state on page load // Restore sidebar state on page load
@@ -124,18 +99,12 @@ document.addEventListener('DOMContentLoaded', function() {
if (isDashboard) { if (isDashboard) {
// Dashboard-specific initialization // Dashboard-specific initialization
initStatusFilter();
initTableSorting();
initSidebarFilters(); initSidebarFilters();
} }
// Initialize for all pages // Initialize for all pages
initSettingsModal(); initSettingsModal();
// Force dark mode only (terminal aesthetic - no theme switching)
document.documentElement.setAttribute('data-theme', 'dark');
document.body.classList.add('dark-mode');
// Event delegation for dynamically created modals // Event delegation for dynamically created modals
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
const target = e.target.closest('[data-action]'); const target = e.target.closest('[data-action]');
@@ -143,7 +112,30 @@ document.addEventListener('DOMContentLoaded', function() {
const action = target.dataset.action; const action = target.dataset.action;
switch (action) { switch (action) {
// Bulk operations // Navigation
case 'navigate':
if (target.dataset.url) window.location.href = target.dataset.url;
break;
case 'view-ticket':
if (target.dataset.ticketId) window.location.href = '/ticket/' + target.dataset.ticketId;
break;
// Bulk action triggers (show modals)
case 'bulk-status':
if (typeof showBulkStatusModal === 'function') showBulkStatusModal();
break;
case 'bulk-assign':
if (typeof showBulkAssignModal === 'function') showBulkAssignModal();
break;
case 'bulk-priority':
if (typeof showBulkPriorityModal === 'function') showBulkPriorityModal();
break;
case 'bulk-delete':
showBulkDeleteModal();
break;
case 'clear-selection':
clearSelection();
break;
// Bulk operation perform actions
case 'perform-bulk-assign': case 'perform-bulk-assign':
performBulkAssign(); performBulkAssign();
break; break;
@@ -168,26 +160,63 @@ document.addEventListener('DOMContentLoaded', function() {
case 'close-bulk-delete-modal': case 'close-bulk-delete-modal':
closeBulkDeleteModal(); closeBulkDeleteModal();
break; break;
// Checkbox selection
case 'toggle-select-all':
toggleSelectAll();
break;
case 'update-selection':
updateSelectionCount();
break;
case 'toggle-row-checkbox':
toggleRowCheckbox(e, target);
break;
// Quick actions // Quick actions
case 'quick-status':
quickStatusChange(target.dataset.ticketId, target.dataset.status);
break;
case 'perform-quick-status': case 'perform-quick-status':
performQuickStatusChange(target.dataset.ticketId); performQuickStatusChange(target.dataset.ticketId);
break; break;
case 'close-quick-status-modal': case 'close-quick-status-modal':
closeQuickStatusModal(); closeQuickStatusModal();
break; break;
case 'quick-assign':
quickAssign(target.dataset.ticketId);
break;
case 'perform-quick-assign': case 'perform-quick-assign':
performQuickAssign(target.dataset.ticketId); performQuickAssign(target.dataset.ticketId);
break; break;
case 'close-quick-assign-modal': case 'close-quick-assign-modal':
closeQuickAssignModal(); closeQuickAssignModal();
break; break;
// View mode toggle
case 'set-view-mode':
if (target.dataset.mode === 'card') populateKanbanCards();
break;
// Settings
case 'open-settings':
case 'open-settings-modal':
if (typeof openSettingsModal === 'function') openSettingsModal();
break;
// Refresh
case 'manual-refresh':
window.location.reload();
break;
// Export
case 'toggle-export-menu':
toggleExportMenu(e);
break;
case 'export-tickets':
exportSelectedTickets(target.dataset.format);
break;
// Advanced search
case 'open-advanced-search':
if (typeof openAdvancedSearch === 'function') openAdvancedSearch();
break;
// Mobile navigation // Mobile navigation
case 'open-mobile-sidebar': case 'open-mobile-sidebar':
if (typeof openMobileSidebar === 'function') openMobileSidebar(); if (typeof openMobileSidebar === 'function') openMobileSidebar();
break; break;
case 'open-settings-modal':
if (typeof openSettingsModal === 'function') openSettingsModal();
break;
// Filter badge actions // Filter badge actions
case 'remove-filter': case 'remove-filter':
removeFilter(target.dataset.filterType, target.dataset.filterValue); removeFilter(target.dataset.filterType, target.dataset.filterValue);
@@ -259,13 +288,11 @@ function clearAllFilters() {
} }
function initTableSorting() { function initTableSorting() {
const tableHeaders = document.querySelectorAll('th'); // Use the TDS lt.sortTable helper which manages aria-sort attributes correctly.
tableHeaders.forEach((header, index) => { // Falls back to no-op if the table isn't present on this page.
header.addEventListener('click', () => { if (window.lt && lt.sortTable && document.getElementById('tickets-table')) {
const table = header.closest('table'); lt.sortTable.init('tickets-table');
sortTable(table, index); }
});
});
} }
function initSidebarFilters() { function initSidebarFilters() {
@@ -416,233 +443,6 @@ function sortTable(table, column) {
// Old settings modal functions removed - now using settings.js with new settings modal // Old settings modal functions removed - now using settings.js with new settings modal
function initStatusFilter() {
const filterContainer = document.createElement('div');
filterContainer.className = 'status-filter-container';
const dropdown = document.createElement('div');
dropdown.className = 'status-dropdown';
const dropdownHeader = document.createElement('div');
dropdownHeader.className = 'dropdown-header';
dropdownHeader.textContent = 'Status Filter';
const dropdownContent = document.createElement('div');
dropdownContent.className = 'dropdown-content';
const statuses = ['Open', 'In Progress', 'Closed'];
statuses.forEach(status => {
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = status;
checkbox.id = `status-${status.toLowerCase().replace(/\s+/g, '-')}`;
const urlParams = new URLSearchParams(window.location.search);
const currentStatuses = urlParams.get('status') ? urlParams.get('status').split(',') : [];
const showAll = urlParams.get('show_all');
// FIXED LOGIC: Determine checkbox state
if (showAll === '1') {
// If show_all=1 parameter exists, all should be checked
checkbox.checked = true;
} else if (currentStatuses.length === 0) {
// No status parameter - default: Open and In Progress checked, Closed unchecked
checkbox.checked = status !== 'Closed';
} else {
// Status parameter exists - check if this status is in the list
checkbox.checked = currentStatuses.includes(status);
}
label.appendChild(checkbox);
label.appendChild(document.createTextNode(' ' + status));
dropdownContent.appendChild(label);
});
const saveButton = document.createElement('button');
saveButton.className = 'btn save-filter';
saveButton.textContent = 'Apply Filter';
saveButton.onclick = () => {
const checkedBoxes = dropdownContent.querySelectorAll('input:checked');
const selectedStatuses = Array.from(checkedBoxes).map(cb => cb.value);
const params = new URLSearchParams(window.location.search);
if (selectedStatuses.length === 0) {
// No statuses selected - show default (Open + In Progress)
params.delete('status');
params.delete('show_all');
} else if (selectedStatuses.length === 3) {
// All statuses selected - show all tickets
params.delete('status');
params.set('show_all', '1');
} else {
// Some statuses selected - set the parameter
params.set('status', selectedStatuses.join(','));
params.delete('show_all');
}
params.set('page', '1');
window.location.search = params.toString();
dropdown.classList.remove('active');
};
dropdownHeader.onclick = () => {
dropdown.classList.toggle('active');
};
dropdown.appendChild(dropdownHeader);
dropdown.appendChild(dropdownContent);
dropdownContent.appendChild(saveButton);
filterContainer.appendChild(dropdown);
const tableActions = document.querySelector('.table-controls .table-actions');
if (tableActions) {
tableActions.prepend(filterContainer);
}
}
function quickSave() {
if (!window.ticketData) {
return;
}
const statusSelect = document.getElementById('status-select');
const prioritySelect = document.getElementById('priority-select');
if (!statusSelect || !prioritySelect) {
return;
}
const data = {
ticket_id: parseInt(window.ticketData.id),
status: statusSelect.value,
priority: parseInt(prioritySelect.value)
};
lt.api.post('/api/update_ticket.php', data)
.then(result => {
if (result.success) {
// Update the hamburger menu display
const hamburgerStatus = document.getElementById('hamburger-status');
const hamburgerPriority = document.getElementById('hamburger-priority');
if (hamburgerStatus) hamburgerStatus.textContent = statusSelect.value;
if (hamburgerPriority) hamburgerPriority.textContent = 'P' + prioritySelect.value;
// Update window.ticketData
window.ticketData.status = statusSelect.value;
window.ticketData.priority = parseInt(prioritySelect.value);
// Update main page elements if they exist
const statusDisplay = document.getElementById('statusDisplay');
if (statusDisplay) {
statusDisplay.className = `status-${statusSelect.value}`;
statusDisplay.textContent = statusSelect.value;
}
// Close hamburger menu after successful save
const hamburgerContent = document.querySelector('.hamburger-content');
if (hamburgerContent) {
hamburgerContent.classList.remove('open');
document.body.classList.remove('menu-open');
}
} else {
lt.toast.error('Error updating ticket: ' + (result.error || 'Unknown error'), 5000);
}
})
.catch(error => {
lt.toast.error('Error updating ticket: ' + error.message, 5000);
});
}
// Ticket page functions (if needed)
function saveTicket() {
const editables = document.querySelectorAll('.editable');
const data = {};
const ticketId = getTicketIdFromUrl();
editables.forEach(field => {
if (field.dataset.field) {
data[field.dataset.field] = field.value;
}
});
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, ...data })
.then(data => {
if(data.success) {
const statusDisplay = document.getElementById('statusDisplay');
if (statusDisplay) {
statusDisplay.className = `status-${data.status}`;
statusDisplay.textContent = data.status;
}
} else {
lt.toast.error('Error saving ticket: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
lt.toast.error('Error saving ticket: ' + error.message);
});
}
/**
* Load template data into the create ticket form
*/
function loadTemplate() {
const templateSelect = document.getElementById('templateSelect');
if (!templateSelect) return;
const templateId = templateSelect.value;
if (!templateId) {
// Clear form when "No Template" is selected
document.getElementById('title').value = '';
document.getElementById('description').value = '';
document.getElementById('priority').value = '4';
document.getElementById('category').value = 'General';
document.getElementById('type').value = 'Issue';
const assignedToSelect = document.getElementById('assigned_to');
if (assignedToSelect) {
assignedToSelect.value = '';
}
return;
}
// Fetch template data
lt.api.get(`/api/get_template.php?template_id=${templateId}`)
.then(data => {
if (data.success && data.template) {
const template = data.template;
// Populate form fields with template data
if (template.title_template) {
document.getElementById('title').value = template.title_template;
}
if (template.description_template) {
document.getElementById('description').value = template.description_template;
}
if (template.category) {
document.getElementById('category').value = template.category;
}
if (template.type) {
document.getElementById('type').value = template.type;
}
if (template.default_priority) {
document.getElementById('priority').value = template.default_priority;
}
} else {
lt.toast.error('Failed to load template: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
lt.toast.error('Error loading template: ' + error.message, 4000);
});
}
/** /**
* Bulk Actions Functions (Admin only) * Bulk Actions Functions (Admin only)
@@ -682,14 +482,14 @@ function updateSelectionCount() {
const exportDropdown = document.getElementById('exportDropdown'); const exportDropdown = document.getElementById('exportDropdown');
const exportCount = document.getElementById('exportCount'); const exportCount = document.getElementById('exportCount');
if (toolbar && countDisplay) { if (toolbar) {
toolbar.classList.toggle('is-visible', count > 0); toolbar.style.display = count > 0 ? 'flex' : 'none';
if (count > 0) countDisplay.textContent = count; if (count > 0 && countDisplay) countDisplay.textContent = count;
} }
// Show/hide export dropdown based on selection // Show/hide export dropdown based on selection
if (exportDropdown) { if (exportDropdown) {
exportDropdown.classList.toggle('is-visible', count > 0); exportDropdown.style.display = count > 0 ? 'inline-flex' : 'none';
if (count > 0 && exportCount) exportCount.textContent = count; if (count > 0 && exportCount) exportCount.textContent = count;
} }
} }
@@ -745,6 +545,8 @@ function performBulkCloseAction(ticketIds) {
}); });
} }
var _bulkAssignUserId = null; // set by combobox onSelect
function showBulkAssignModal() { function showBulkAssignModal() {
const ticketIds = getSelectedTicketIds(); const ticketIds = getSelectedTicketIds();
@@ -753,7 +555,8 @@ function showBulkAssignModal() {
return; return;
} }
// Create modal HTML _bulkAssignUserId = null;
const modalHtml = ` const modalHtml = `
<div class="lt-modal-overlay" id="bulkAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkAssignModalTitle"> <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">
@@ -762,10 +565,15 @@ function showBulkAssignModal() {
<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">
<label for="bulkAssignUser">Assign to:</label> <label class="lt-label">Assign to:</label>
<select id="bulkAssignUser" class="lt-select"> <div class="lt-combobox" id="bulkAssignCombobox">
<option value="">Select User...</option> <div class="lt-combobox-input-wrap">
</select> <input type="text" class="lt-combobox-input" id="bulkAssignUserInput"
placeholder="Search users…" autocomplete="off" aria-label="Search users">
</div>
<ul class="lt-combobox-list" role="listbox" aria-hidden="true"></ul>
</div>
<span class="lt-field-hint lt-text-muted">Type to search — supports large user lists</span>
</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>
@@ -778,19 +586,18 @@ function showBulkAssignModal() {
document.body.insertAdjacentHTML('beforeend', modalHtml); document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkAssignModal'); lt.modal.open('bulkAssignModal');
// Fetch users for the dropdown
lt.api.get('/api/get_users.php') lt.api.get('/api/get_users.php')
.then(data => { .then(data => {
if (data.success && data.users) { if (data.success && data.users) {
const select = document.getElementById('bulkAssignUser'); const input = document.getElementById('bulkAssignUserInput');
if (select) { if (!input) return;
data.users.forEach(user => { const items = data.users.map(u => ({
const option = document.createElement('option'); value: String(u.user_id),
option.value = user.user_id; label: u.display_name || u.username
option.textContent = user.display_name || user.username; }));
select.appendChild(option); lt.combobox.init(input, items, {
}); onSelect: function(item) { _bulkAssignUserId = item.value; }
} });
} }
}) })
.catch(() => lt.toast.error('Error loading users')); .catch(() => lt.toast.error('Error loading users'));
@@ -803,11 +610,11 @@ function closeBulkAssignModal() {
} }
function performBulkAssign() { function performBulkAssign() {
const userId = document.getElementById('bulkAssignUser').value; const userId = _bulkAssignUserId;
const ticketIds = getSelectedTicketIds(); const ticketIds = getSelectedTicketIds();
if (!userId) { if (!userId) {
lt.toast.warning('Please select a user', 2000); lt.toast.warning('Please select a user from the list', 2000);
return; return;
} }
@@ -960,10 +767,7 @@ function showBulkStatusModal() {
<label for="bulkStatus">New Status:</label> <label for="bulkStatus">New Status:</label>
<select id="bulkStatus" class="lt-select"> <select id="bulkStatus" class="lt-select">
<option value="">Select Status...</option> <option value="">Select Status...</option>
<option value="Open">Open</option> ${(window.TICKET_STATUSES || ['Open','Pending','In Progress','Closed']).map(s => `<option value="${s}">${s}</option>`).join('')}
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select> </select>
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
@@ -1032,9 +836,9 @@ function showBulkDeleteModal() {
<span class="lt-modal-title" id="bulkDeleteModalTitle">[ ! ] 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 aria-label="Close">✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<div class="lt-modal-body text-center"> <div class="lt-modal-body" style="text-align:center">
<p class="modal-warning-text">This action cannot be undone!</p> <p class="lt-text-danger lt-text-sm">This action cannot be undone!</p>
<p class="text-green">You are about to permanently delete ${ticketIds.length} ticket(s).<br>All associated comments and history will be lost.</p> <p class="lt-text-cyan">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-danger">DELETE PERMANENTLY</button> <button data-action="perform-bulk-delete" class="lt-btn lt-btn-danger">DELETE PERMANENTLY</button>
@@ -1156,8 +960,8 @@ function quickStatusChange(ticketId, currentStatus) {
<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">
<p class="mb-half">Ticket #${lt.escHtml(ticketId)}</p> <p class="lt-mb-xs">Ticket #${lt.escHtml(ticketId)}</p>
<p class="text-amber mb-half">Current: ${lt.escHtml(currentStatus)}</p> <p class="lt-text-amber lt-mb-xs">Current: ${lt.escHtml(currentStatus)}</p>
<label for="quickStatusSelect">New Status:</label> <label for="quickStatusSelect">New Status:</label>
<select id="quickStatusSelect" class="lt-select"> <select id="quickStatusSelect" class="lt-select">
${otherStatuses.map(s => `<option value="${s}">${s}</option>`).join('')} ${otherStatuses.map(s => `<option value="${s}">${s}</option>`).join('')}
@@ -1200,10 +1004,14 @@ function performQuickStatusChange(ticketId) {
}); });
} }
var _quickAssignUserId = undefined; // undefined = no change; null = unassign; string = user_id
/** /**
* Quick assign from dashboard * Quick assign from dashboard
*/ */
function quickAssign(ticketId) { function quickAssign(ticketId) {
_quickAssignUserId = undefined;
const modalHtml = ` const modalHtml = `
<div class="lt-modal-overlay" id="quickAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="quickAssignModalTitle"> <div class="lt-modal-overlay" id="quickAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="quickAssignModalTitle">
<div class="lt-modal lt-modal-xs"> <div class="lt-modal lt-modal-xs">
@@ -1212,14 +1020,18 @@ function quickAssign(ticketId) {
<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">
<p class="mb-half">Ticket #${lt.escHtml(ticketId)}</p> <p class="lt-mb-xs lt-text-muted lt-text-xs">Ticket #${lt.escHtml(String(ticketId))}</p>
<label for="quickAssignSelect">Assign to:</label> <label class="lt-label">Assign to:</label>
<select id="quickAssignSelect" class="lt-select"> <div class="lt-combobox" id="quickAssignCombobox">
<option value="">Unassigned</option> <div class="lt-combobox-input-wrap">
</select> <input type="text" class="lt-combobox-input" id="quickAssignInput"
placeholder="Search users…" autocomplete="off" aria-label="Search users">
</div>
<ul class="lt-combobox-list" role="listbox" aria-hidden="true"></ul>
</div>
</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="${lt.escHtml(String(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>
@@ -1229,16 +1041,20 @@ function quickAssign(ticketId) {
document.body.insertAdjacentHTML('beforeend', modalHtml); document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('quickAssignModal'); lt.modal.open('quickAssignModal');
// Load users
lt.api.get('/api/get_users.php') lt.api.get('/api/get_users.php')
.then(data => { .then(data => {
if (data.success && data.users) { if (data.success && data.users) {
const select = document.getElementById('quickAssignSelect'); const input = document.getElementById('quickAssignInput');
data.users.forEach(user => { if (!input) return;
const option = document.createElement('option'); const items = [
option.value = user.user_id; { value: '', label: 'Unassigned' },
option.textContent = user.display_name || user.username; ...data.users.map(u => ({
select.appendChild(option); value: String(u.user_id),
label: u.display_name || u.username
}))
];
lt.combobox.init(input, items, {
onSelect: function(item) { _quickAssignUserId = item.value || null; }
}); });
} }
}) })
@@ -1252,7 +1068,11 @@ function closeQuickAssignModal() {
} }
function performQuickAssign(ticketId) { function performQuickAssign(ticketId) {
const assignedTo = document.getElementById('quickAssignSelect').value || null; if (_quickAssignUserId === undefined) {
lt.toast.warning('Please select a user from the list', 2000);
return;
}
const assignedTo = _quickAssignUserId;
lt.api.post('/api/assign_ticket.php', { ticket_id: ticketId, assigned_to: assignedTo }) lt.api.post('/api/assign_ticket.php', { ticket_id: ticketId, assigned_to: assignedTo })
.then(data => { .then(data => {
@@ -1278,27 +1098,10 @@ function performQuickAssign(ticketId) {
* Set the view mode (table or card) * Set the view mode (table or card)
*/ */
function setViewMode(mode) { function setViewMode(mode) {
const tableView = document.querySelector('.ascii-frame-outer'); // TDS v1.2 uses lt.tabs for show/hide; we just need to populate kanban cards
const cardView = document.getElementById('cardView');
const tableBtn = document.getElementById('tableViewBtn');
const cardBtn = document.getElementById('cardViewBtn');
if (!tableView || !cardView) return;
if (mode === 'card') { if (mode === 'card') {
tableView.classList.add('is-hidden');
cardView.classList.remove('is-hidden');
tableBtn.classList.remove('active');
cardBtn.classList.add('active');
populateKanbanCards(); populateKanbanCards();
} else {
tableView.classList.remove('is-hidden');
cardView.classList.add('is-hidden');
tableBtn.classList.add('active');
cardBtn.classList.remove('active');
} }
// Store preference
localStorage.setItem('ticketViewMode', mode); localStorage.setItem('ticketViewMode', mode);
} }
@@ -1306,18 +1109,17 @@ function setViewMode(mode) {
* Populate Kanban cards from table data * Populate Kanban cards from table data
*/ */
function populateKanbanCards() { function populateKanbanCards() {
const rows = document.querySelectorAll('tbody tr'); const rows = document.querySelectorAll('#tickets-table tbody tr');
// TDS v1.2 kanban columns use id="kanban-col-{slug}" with .kanban-cards child
const columns = { const columns = {
'Open': document.querySelector('.kanban-column[data-status="Open"] .kanban-cards'), 'Open': document.getElementById('kanban-col-open'),
'Pending': document.querySelector('.kanban-column[data-status="Pending"] .kanban-cards'), 'Pending': document.getElementById('kanban-col-pending'),
'In Progress': document.querySelector('.kanban-column[data-status="In Progress"] .kanban-cards'), 'In Progress': document.getElementById('kanban-col-inprogress'),
'Closed': document.querySelector('.kanban-column[data-status="Closed"] .kanban-cards') 'Closed': document.getElementById('kanban-col-closed'),
}; };
// Clear existing cards // Clear existing cards
Object.values(columns).forEach(col => { Object.values(columns).forEach(col => { if (col) col.innerHTML = ''; });
if (col) col.innerHTML = '';
});
const counts = { 'Open': 0, 'Pending': 0, 'In Progress': 0, 'Closed': 0 }; const counts = { 'Open': 0, 'Pending': 0, 'In Progress': 0, 'Closed': 0 };
const isAdmin = document.getElementById('selectAllCheckbox') !== null; const isAdmin = document.getElementById('selectAllCheckbox') !== null;
@@ -1325,53 +1127,60 @@ function populateKanbanCards() {
rows.forEach(row => { rows.forEach(row => {
const cells = row.querySelectorAll('td'); const cells = row.querySelectorAll('td');
if (cells.length < 6) return; // Skip empty rows if (cells.length < 6) return;
const ticketId = cells[0 + offset]?.querySelector('.ticket-link')?.textContent.trim() || ''; const ticketId = cells[0 + offset]?.querySelector('.ticket-link')?.textContent.trim() || '';
const priority = cells[1 + offset]?.textContent.trim() || ''; const priorityEl = cells[1 + offset]?.querySelector('[class*="lt-p"]');
const title = cells[2 + offset]?.textContent.trim() || ''; const priority = priorityEl ? priorityEl.textContent.trim().replace('P','') : cells[1 + offset]?.textContent.trim() || '4';
const category = cells[3 + offset]?.textContent.trim() || ''; const title = cells[2 + offset]?.textContent.trim() || '';
const status = cells[5 + offset]?.textContent.trim() || ''; const category = cells[3 + offset]?.textContent.trim() || '';
const statusEl = cells[5 + offset]?.querySelector('.lt-status');
const status = statusEl ? statusEl.textContent.trim() : cells[5 + offset]?.textContent.trim() || '';
const assignedTo = cells[7 + offset]?.textContent.trim() || 'Unassigned'; const assignedTo = cells[7 + offset]?.textContent.trim() || 'Unassigned';
// Get initials for assignee const initials = assignedTo === 'Unassigned' ? '?'
const initials = assignedTo === 'Unassigned' ? '?' : : assignedTo.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
assignedTo.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
const column = columns[status]; const column = columns[status];
if (column) { if (!column || !ticketId) return;
counts[status]++;
const card = document.createElement('div'); counts[status] = (counts[status] || 0) + 1;
card.className = `kanban-card priority-${priority}`;
card.onclick = () => window.location.href = `/ticket/${ticketId}`; const pNum = parseInt(priority, 10) || 4;
card.innerHTML = ` const card = document.createElement('div');
<div class="card-header"> card.className = 'lt-kanban-card lt-kanban-card--p' + pNum;
<span class="card-id">#${lt.escHtml(ticketId)}</span> card.setAttribute('role', 'button');
<span class="lt-priority lt-p${priority}"></span> card.setAttribute('tabindex', '0');
</div> card.onclick = () => window.location.href = '/ticket/' + encodeURIComponent(ticketId);
<div class="card-title">${lt.escHtml(title)}</div> card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); };
<div class="card-footer"> card.innerHTML =
<span class="card-category">${lt.escHtml(category)}</span> '<div class="lt-kanban-card-header">' +
<span class="card-assignee" title="${lt.escHtml(assignedTo)}">${lt.escHtml(initials)}</span> '<span class="lt-text-xs lt-text-cyan">#' + lt.escHtml(ticketId) + '</span>' +
</div> '<span class="lt-p' + pNum + ' lt-text-xs">P' + pNum + '</span>' +
`; '</div>' +
column.appendChild(card); '<div class="lt-kanban-card-title">' + lt.escHtml(title) + '</div>' +
} '<div class="lt-kanban-card-footer">' +
'<span class="lt-text-xs lt-text-muted">' + lt.escHtml(category) + '</span>' +
'<span class="lt-kanban-assignee lt-text-xs" title="' + lt.escHtml(assignedTo) + '">' + lt.escHtml(initials) + '</span>' +
'</div>';
column.appendChild(card);
}); });
// Update column counts // Update column counts
Object.keys(counts).forEach(status => { document.querySelectorAll('.column-count[data-status]').forEach(el => {
const header = document.querySelector(`.kanban-column[data-status="${status}"] .column-count`); const s = el.dataset.status;
if (header) header.textContent = counts[status]; el.textContent = '(' + (counts[s] || 0) + ')';
}); });
} }
// Restore view mode on page load // Restore view mode on page load — click the kanban tab button to trigger lt.tabs
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem('ticketViewMode'); const savedMode = localStorage.getItem('ticketViewMode');
if (savedMode === 'card') { if (savedMode === 'card') {
setViewMode('card'); const cardBtn = document.getElementById('cardViewBtn');
if (cardBtn) cardBtn.click();
else populateKanbanCards();
} }
}); });
@@ -1439,7 +1248,7 @@ function showTicketPreview(event) {
currentPreview.innerHTML = ` currentPreview.innerHTML = `
<div class="preview-header"> <div class="preview-header">
<span class="preview-id">#${lt.escHtml(ticketId)}</span> <span class="preview-id">#${lt.escHtml(ticketId)}</span>
<span class="preview-status status-${status.replace(/\s+/g, '-')}">${lt.escHtml(status)}</span> <span class="preview-status status-${lt.escHtml(status.replace(/\s+/g, '-'))}">${lt.escHtml(status)}</span>
</div> </div>
<div class="preview-title">${lt.escHtml(title)}</div> <div class="preview-title">${lt.escHtml(title)}</div>
<div class="preview-meta"> <div class="preview-meta">
@@ -1537,104 +1346,39 @@ function exportSelectedTickets(format) {
if (dropdown) dropdown.classList.remove('open'); if (dropdown) dropdown.classList.remove('open');
} }
// ========================================
// Skeleton Loading Helpers
// ========================================
/** /**
* Generate skeleton table rows * Show TDS spinner overlay on an element.
*/ * Uses lt-spinner + lt-loading-text from base.css.
function generateSkeletonRows(count = 5) {
let html = '';
for (let i = 0; i < count; i++) {
html += `
<tr class="skeleton-row-tr">
<td><div class="skeleton skeleton-text" style="width: 80px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 40px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: ${70 + Math.random() * 30}%;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 60px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 50px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 70px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 80px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 80px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 70px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 70px;"></div></td>
<td><div class="skeleton skeleton-text" style="width: 60px;"></div></td>
</tr>
`;
}
return html;
}
/**
* Generate skeleton comments
*/
function generateSkeletonComments(count = 3) {
let html = '';
for (let i = 0; i < count; i++) {
html += `
<div class="skeleton-comment">
<div class="skeleton-comment-header">
<div class="skeleton skeleton-avatar"></div>
<div class="skeleton-comment-meta">
<div class="skeleton skeleton-text short"></div>
<div class="skeleton skeleton-text" style="width: 100px;"></div>
</div>
</div>
<div class="skeleton skeleton-text long"></div>
<div class="skeleton skeleton-text medium"></div>
<div class="skeleton skeleton-text short"></div>
</div>
`;
}
return html;
}
/**
* Generate skeleton stat cards
*/
function generateSkeletonStats(count = 4) {
let html = '';
for (let i = 0; i < count; i++) {
html += `
<div class="skeleton-stat skeleton">
<div class="skeleton skeleton-value"></div>
<div class="skeleton skeleton-label"></div>
</div>
`;
}
return html;
}
/**
* Show loading overlay on element
*/ */
function showLoadingOverlay(element, message = 'Loading...') { function showLoadingOverlay(element, message = 'Loading...') {
// Remove existing overlay const existing = element.querySelector('.lt-loading-overlay');
const existing = element.querySelector('.loading-overlay');
if (existing) existing.remove(); if (existing) existing.remove();
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'loading-overlay'; overlay.className = 'lt-loading-overlay';
overlay.innerHTML = `
<div class="loading-spinner"></div> const spinner = document.createElement('div');
<div class="loading-text">${message}</div> spinner.className = 'lt-spinner';
`;
element.classList.add('has-overlay'); const text = document.createElement('div');
text.className = 'lt-loading-text';
text.textContent = message;
overlay.appendChild(spinner);
overlay.appendChild(text);
element.classList.add('has-lt-overlay');
element.appendChild(overlay); element.appendChild(overlay);
} }
/** /**
* Hide loading overlay * Hide TDS spinner overlay
*/ */
function hideLoadingOverlay(element) { function hideLoadingOverlay(element) {
const overlay = element.querySelector('.loading-overlay'); const overlay = element.querySelector('.lt-loading-overlay');
if (overlay) { if (overlay) {
overlay.classList.add('loading-overlay--hiding'); overlay.remove();
setTimeout(() => { element.classList.remove('has-lt-overlay');
overlay.remove();
element.classList.remove('has-overlay');
}, 300);
} }
} }
@@ -1682,7 +1426,5 @@ setInterval(initRelativeTimes, 60000);
// Export for use in other scripts // Export for use in other scripts
window.generateSkeletonRows = generateSkeletonRows;
window.generateSkeletonComments = generateSkeletonComments;
window.showLoadingOverlay = showLoadingOverlay; window.showLoadingOverlay = showLoadingOverlay;
window.hideLoadingOverlay = hideLoadingOverlay; window.hideLoadingOverlay = hideLoadingOverlay;
+2 -56
View File
@@ -26,60 +26,6 @@ function navigateTableRow(direction) {
} }
} }
function showKeyboardHelp() {
if (document.getElementById('keyboardHelpModal')) return;
const modal = document.createElement('div');
modal.id = 'keyboardHelpModal';
modal.className = 'lt-modal-overlay';
modal.setAttribute('aria-hidden', 'true');
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', 'keyboardHelpModalTitle');
modal.innerHTML = `
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header">
<span class="lt-modal-title" id="keyboardHelpModalTitle">KEYBOARD SHORTCUTS</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<h4 class="kb-section-heading">Navigation</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>J</kbd></td><td>Next ticket in list</td></tr>
<tr><td><kbd>K</kbd></td><td>Previous ticket in list</td></tr>
<tr><td><kbd>Enter</kbd></td><td>Open selected ticket</td></tr>
<tr><td><kbd>G</kbd> then <kbd>D</kbd></td><td>Go to Dashboard</td></tr>
</table>
<h4 class="kb-section-heading">Actions</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>N</kbd></td><td>New ticket</td></tr>
<tr><td><kbd>C</kbd></td><td>Focus comment box</td></tr>
<tr><td><kbd>Ctrl/Cmd+E</kbd></td><td>Toggle Edit Mode</td></tr>
<tr><td><kbd>Ctrl/Cmd+S</kbd></td><td>Save Changes</td></tr>
</table>
<h4 class="kb-section-heading">Quick Status (Ticket Page)</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>1</kbd></td><td>Set Open</td></tr>
<tr><td><kbd>2</kbd></td><td>Set Pending</td></tr>
<tr><td><kbd>3</kbd></td><td>Set In Progress</td></tr>
<tr><td><kbd>4</kbd></td><td>Set Closed</td></tr>
</table>
<h4 class="kb-section-heading">Other</h4>
<table class="kb-shortcuts-table no-margin">
<tr><td><kbd>Ctrl/Cmd+K</kbd></td><td>Focus Search</td></tr>
<tr><td><kbd>ESC</kbd></td><td>Close Modal / Cancel</td></tr>
<tr><td><kbd>?</kbd></td><td>Show This Help</td></tr>
</table>
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-ghost" data-modal-close>CLOSE</button>
</div>
</div>
`;
document.body.appendChild(modal);
lt.modal.open('keyboardHelpModal');
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (!window.lt) return; if (!window.lt) return;
@@ -101,9 +47,9 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
// ?: Show keyboard shortcuts help (lt.keys.initDefaults also handles this, but we override to show our modal) // ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer
lt.keys.on('?', function() { lt.keys.on('?', function() {
showKeyboardHelp(); if (window.lt) lt.modal.open('lt-keys-help');
}); });
// J: Next row // J: Next row
+84 -66
View File
@@ -47,18 +47,29 @@ function saveTicket() {
} }
} }
// Include optimistic lock timestamp so the server can detect concurrent edits
if (window.ticketData && window.ticketData.updated_at) {
data.expected_updated_at = window.ticketData.updated_at;
}
// Use the correct API path // Use the correct API path
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, ...data }) lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, ...data })
.then(data => { .then(resp => {
if (data.success) { if (resp.success) {
const statusDisplay = document.getElementById('statusDisplay'); const statusDisplay = document.getElementById('statusDisplay');
if (statusDisplay) { if (statusDisplay) {
statusDisplay.className = `status-${data.status}`; statusDisplay.className = `status-${resp.status}`;
statusDisplay.textContent = data.status; statusDisplay.textContent = resp.status;
}
// Keep local updated_at in sync so the next save uses the right lock key
if (resp.updated_at && window.ticketData) {
window.ticketData.updated_at = resp.updated_at;
} }
lt.toast.success('Ticket updated successfully'); lt.toast.success('Ticket updated successfully');
} else if (resp.conflict) {
lt.toast.error('This ticket was modified by someone else while you were editing. Reload to see the latest version.', 8000);
} else { } else {
lt.toast.error('Error saving ticket: ' + (data.error || 'Unknown error')); lt.toast.error('Error saving ticket: ' + (resp.error || 'Unknown error'));
} }
}) })
.catch(error => { .catch(error => {
@@ -66,6 +77,38 @@ function saveTicket() {
}); });
} }
// ── Description read/edit helpers ────────────────────────────────────────────
// Read mode: styled lt-markdown div (full contrast, even on OLED).
// Edit mode: raw textarea (enabled for editing).
function renderDescriptionView() {
var viewDiv = document.getElementById('ticketDescriptionView');
var textarea = document.querySelector('textarea[data-field="description"]');
if (!viewDiv || !textarea) return;
var raw = textarea.value || '';
if (!raw.trim()) {
viewDiv.innerHTML = '<p class="lt-text-muted lt-text-sm"><em>No description provided.</em></p>';
} else {
// Ticket descriptions are plain text. CSS white-space:pre-wrap handles
// line breaks and multiple spaces (ASCII art) — no <br> replacement needed.
viewDiv.innerHTML = lt.escHtml(raw);
}
}
function showDescriptionView() {
var v = document.getElementById('ticketDescriptionView');
var t = document.querySelector('textarea[data-field="description"]');
if (v) v.style.display = '';
if (t) t.style.display = 'none';
}
function showDescriptionEdit() {
var v = document.getElementById('ticketDescriptionView');
var t = document.querySelector('textarea[data-field="description"]');
if (v) v.style.display = 'none';
if (t) t.style.display = '';
}
function toggleEditMode() { function toggleEditMode() {
const editButton = document.getElementById('editButton'); const editButton = document.getElementById('editButton');
const titleField = document.querySelector('.title-input'); const titleField = document.querySelector('.title-input');
@@ -83,16 +126,17 @@ function toggleEditMode() {
titleField.focus(); titleField.focus();
} }
// Enable description (textarea) // Enable description (swap to textarea)
if (descriptionField) { if (descriptionField) {
showDescriptionEdit();
descriptionField.disabled = false; descriptionField.disabled = false;
descriptionField.style.height = 'auto'; descriptionField.style.height = 'auto';
descriptionField.style.height = descriptionField.scrollHeight + 'px'; descriptionField.style.height = descriptionField.scrollHeight + 'px';
} }
// Enable metadata fields (priority, category, type) // Enable metadata fields (priority, category, type) — remove display-only class
metadataFields.forEach(field => { metadataFields.forEach(field => {
field.disabled = false; field.classList.remove('lt-display-field');
}); });
} else { } else {
saveTicket(); saveTicket();
@@ -104,14 +148,16 @@ function toggleEditMode() {
titleField.setAttribute('contenteditable', 'false'); titleField.setAttribute('contenteditable', 'false');
} }
// Disable description // Re-render description view div with latest content
if (descriptionField) { if (descriptionField) {
descriptionField.disabled = true; descriptionField.disabled = true;
renderDescriptionView();
showDescriptionView();
} }
// Disable metadata fields // Return metadata fields to display-only using .lt-display-field (not disabled)
metadataFields.forEach(field => { metadataFields.forEach(field => {
field.disabled = true; field.classList.add('lt-display-field');
}); });
} }
} }
@@ -248,6 +294,10 @@ document.addEventListener('DOMContentLoaded', function() {
// Show description tab by default // Show description tab by default
showTab('description'); showTab('description');
// Populate and show description view div on page load
renderDescriptionView();
showDescriptionView();
// Auto-resize function for textareas // Auto-resize function for textareas
function autoResizeTextarea(textarea) { function autoResizeTextarea(textarea) {
// Reset height to auto to get the correct scrollHeight // Reset height to auto to get the correct scrollHeight
@@ -410,9 +460,9 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus }) lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
.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 (preserve TDS v1.2 classes)
const newClass = 'status-' + newStatus.toLowerCase().replace(/ /g, '-'); const newClass = 'lt-status-' + newStatus.toLowerCase().replace(/ /g, '-');
statusSelect.className = 'editable status-select ' + newClass; statusSelect.className = 'lt-select lt-select-sm lt-status-select ' + newClass;
// Update the selected option text to show as current // Update the selected option text to show as current
selectedOption.text = newStatus + ' (current)'; selectedOption.text = newStatus + ' (current)';
@@ -440,43 +490,11 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
} }
function showTab(tabName) { function showTab(tabName) {
// Hide all tab contents // Load content for tabs that require it (TDS v1.2 handles the actual show/hide via lt.tabs)
const descriptionTab = document.getElementById('description-tab');
const commentsTab = document.getElementById('comments-tab');
const attachmentsTab = document.getElementById('attachments-tab');
const dependenciesTab = document.getElementById('dependencies-tab');
const activityTab = document.getElementById('activity-tab');
if (!descriptionTab || !commentsTab) {
return;
}
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active'));
// Remove active class and aria-selected from all buttons
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
btn.setAttribute('aria-selected', 'false');
});
// Show selected tab and activate its button
const tabEl = document.getElementById(`${tabName}-tab`);
if (tabEl) tabEl.classList.add('active');
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
if (activeBtn) {
activeBtn.classList.add('active');
activeBtn.setAttribute('aria-selected', 'true');
}
// Load attachments when tab is shown
if (tabName === 'attachments') { if (tabName === 'attachments') {
loadAttachments(); loadAttachments();
initializeUploadZone(); initializeUploadZone();
} } else if (tabName === 'dependencies') {
// Load dependencies when tab is shown
if (tabName === 'dependencies') {
loadDependencies(); loadDependencies();
} }
} }
@@ -507,10 +525,10 @@ function showDependencyError(message) {
const dependentsList = document.getElementById('dependentsList'); const dependentsList = document.getElementById('dependentsList');
if (dependenciesList) { if (dependenciesList) {
dependenciesList.innerHTML = `<p class="text-amber">${lt.escHtml(message)}</p>`; dependenciesList.innerHTML = `<p class="lt-text-amber">${lt.escHtml(message)}</p>`;
} }
if (dependentsList) { if (dependentsList) {
dependentsList.innerHTML = `<p class="text-amber">${lt.escHtml(message)}</p>`; dependentsList.innerHTML = `<p class="lt-text-amber">${lt.escHtml(message)}</p>`;
} }
} }
@@ -544,7 +562,7 @@ function renderDependencies(dependencies) {
<span class="dependency-title">${lt.escHtml(dep.title)}</span> <span class="dependency-title">${lt.escHtml(dep.title)}</span>
<span class="status-badge ${statusClass}">${lt.escHtml(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">REMOVE</button> <button data-action="remove-dependency" data-dependency-id="${lt.escHtml(String(dep.dependency_id))}" class="lt-btn lt-btn-sm">REMOVE</button>
</div>`; </div>`;
}); });
@@ -553,7 +571,7 @@ function renderDependencies(dependencies) {
} }
if (!hasAny) { if (!hasAny) {
html = '<p class="text-muted-green">No dependencies configured.</p>'; html = '<p class="lt-text-muted">No dependencies configured.</p>';
} }
container.innerHTML = html; container.innerHTML = html;
@@ -564,7 +582,7 @@ function renderDependents(dependents) {
if (!container) return; if (!container) return;
if (dependents.length === 0) { if (dependents.length === 0) {
container.innerHTML = '<p class="text-muted-green">No tickets depend on this one.</p>'; container.innerHTML = '<p class="lt-text-muted">No tickets depend on this one.</p>';
return; return;
} }
@@ -578,7 +596,7 @@ function renderDependents(dependents) {
</a> </a>
<span class="dependency-title">${lt.escHtml(dep.title)}</span> <span class="dependency-title">${lt.escHtml(dep.title)}</span>
<span class="status-badge ${statusClass}">${lt.escHtml(dep.status)}</span> <span class="status-badge ${statusClass}">${lt.escHtml(dep.status)}</span>
<span class="dependency-title text-amber">(${lt.escHtml(dep.dependency_type)})</span> <span class="dependency-title lt-text-amber">(${lt.escHtml(dep.dependency_type)})</span>
</div> </div>
</div>`; </div>`;
}); });
@@ -788,11 +806,11 @@ function loadAttachments() {
if (data.success) { if (data.success) {
renderAttachments(data.attachments || []); renderAttachments(data.attachments || []);
} else { } else {
container.innerHTML = '<p class="text-muted-green">Error loading attachments.</p>'; container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
} }
}) })
.catch(error => { .catch(error => {
container.innerHTML = '<p class="text-muted-green">Error loading attachments.</p>'; container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
}); });
} }
@@ -801,7 +819,7 @@ function renderAttachments(attachments) {
if (!container) return; if (!container) return;
if (attachments.length === 0) { if (attachments.length === 0) {
container.innerHTML = '<p class="text-muted-green">No files attached to this ticket.</p>'; container.innerHTML = '<p class="lt-text-muted">No files attached to this ticket.</p>';
return; return;
} }
@@ -831,8 +849,8 @@ function renderAttachments(attachments) {
</div> </div>
</div> </div>
<div class="attachment-actions"> <div class="attachment-actions">
<a href="/api/download_attachment.php?id=${att.attachment_id}" class="btn btn-small" title="Download">⬇</a> <a href="/api/download_attachment.php?id=${att.attachment_id}" class="lt-btn lt-btn-sm" title="Download">⬇</a>
<button data-action="delete-attachment" data-attachment-id="${att.attachment_id}" class="btn btn-small btn-danger" title="Delete">✕</button> <button data-action="delete-attachment" data-attachment-id="${att.attachment_id}" class="lt-btn lt-btn-sm lt-btn-danger" title="Delete">✕</button>
</div> </div>
</div>`; </div>`;
}); });
@@ -1155,8 +1173,8 @@ function editComment(commentId) {
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="lt-btn lt-btn-sm" 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="lt-btn lt-btn-ghost lt-btn-sm" data-action="cancel-edit-comment" data-comment-id="${commentId}">CANCEL</button>
</div> </div>
</div> </div>
`; `;
@@ -1301,7 +1319,7 @@ function showReplyForm(commentId, userName) {
const replyFormHtml = ` const replyFormHtml = `
<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">@${lt.escHtml(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>
@@ -1311,7 +1329,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="lt-btn lt-btn-sm" data-action="submit-reply" data-parent-id="${commentId}">REPLY</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1406,10 +1424,10 @@ function submitReply(parentCommentId) {
<div class="thread-line"></div> <div class="thread-line"></div>
<div class="comment-content"> <div class="comment-content">
<div class="comment-header"> <div class="comment-header">
<span class="comment-user">${data.user_name}</span> <span class="comment-user">${lt.escHtml(data.user_name)}</span>
<span class="comment-date">${data.created_at}</span> <span class="comment-date">${lt.escHtml(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="${lt.escHtml(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">[ 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">[ DEL ]</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>
+48 -4
View File
@@ -20,6 +20,29 @@ if ($envVars) {
// Global configuration // Global configuration
$GLOBALS['config'] = [ $GLOBALS['config'] = [
// Application identity
'APP_NAME' => $envVars['APP_NAME'] ?? 'TINKER TICKETS',
'APP_SUBTITLE' => $envVars['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure',
'APP_VERSION' => $envVars['APP_VERSION'] ?? '1.2',
// Asset cache-busting version — auto-computed from dashboard.js/css mtime so
// browsers always pick up changes on deploy. Override via ASSET_VERSION in .env.
'ASSET_VERSION' => (function() use ($envVars) {
if (!empty($envVars['ASSET_VERSION'])) return $envVars['ASSET_VERSION'];
$files = [
__DIR__ . '/../assets/css/dashboard.css',
__DIR__ . '/../assets/css/ticket.css',
__DIR__ . '/../assets/js/dashboard.js',
__DIR__ . '/../assets/js/ticket.js',
];
$mtime = 0;
foreach ($files as $f) { if (file_exists($f)) $mtime = max($mtime, filemtime($f)); }
return $mtime ?: '20260329';
})(),
// Canonical ticket statuses — single source of truth used by views and JS
'TICKET_STATUSES' => ['Open', 'Pending', 'In Progress', 'Closed'],
// Database settings // Database settings
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost', 'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
'DB_USER' => $envVars['DB_USER'] ?? 'root', 'DB_USER' => $envVars['DB_USER'] ?? 'root',
@@ -32,9 +55,19 @@ $GLOBALS['config'] = [
'API_URL' => '/api', // API URL 'API_URL' => '/api', // API URL
// Matrix webhook (hookshot generic webhook URL) // Matrix webhook (hookshot generic webhook URL)
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null, 'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
// Comma-separated Matrix user IDs to @mention on new tickets (e.g. @jared:matrix.lotusguild.org) // Comma-separated Matrix user IDs to @mention on new tickets / status changes (e.g. @jared:matrix.lotusguild.org)
'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '', 'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '',
// Matrix homeserver domain (e.g. matrix.lotusguild.org) — used to construct Matrix user IDs
'MATRIX_DOMAIN' => $envVars['MATRIX_DOMAIN'] ?? null,
// Internal Synapse client-API base URL (e.g. http://10.10.10.29:8008) — used to verify user existence via Admin API
'SYNAPSE_ADMIN_URL' => $envVars['SYNAPSE_ADMIN_URL'] ?? null,
// Synapse admin access token (generate with: register_new_matrix_user or admin API)
'SYNAPSE_ADMIN_TOKEN' => $envVars['SYNAPSE_ADMIN_TOKEN'] ?? null,
// Set to '1' or 'true' to send a notification when any comment is posted
'MATRIX_NOTIFY_COMMENTS' => filter_var($envVars['MATRIX_NOTIFY_COMMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
// Set to '1' or 'true' to send a notification when a ticket is assigned
'MATRIX_NOTIFY_ASSIGNMENTS' => filter_var($envVars['MATRIX_NOTIFY_ASSIGNMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
// Domain settings for external integrations (webhooks, links, etc.) // Domain settings for external integrations (webhooks, links, etc.)
// Set APP_DOMAIN in .env to override // Set APP_DOMAIN in .env to override
@@ -87,7 +120,18 @@ $GLOBALS['config'] = [
// Default: America/New_York (EST/EDT) // Default: America/New_York (EST/EDT)
// Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC // Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York', 'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
'TIMEZONE_OFFSET' => null // Will be calculated below 'TIMEZONE_OFFSET' => null, // Will be calculated below
// LDAP / lldap settings (for user avatar lookups)
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? '10.10.10.39',
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 3890),
'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com',
'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '',
'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',
'LDAP_USER_BASE' => $envVars['LDAP_USER_BASE'] ?? 'ou=people,dc=example,dc=com',
'LDAP_ENABLED' => filter_var($envVars['LDAP_ENABLED'] ?? 'true', FILTER_VALIDATE_BOOLEAN),
'AVATAR_CACHE_DIR' => __DIR__ . '/../uploads/avatars',
'AVATAR_CACHE_TTL' => (int)($envVars['AVATAR_CACHE_TTL'] ?? 3600), // seconds
]; ];
// Set PHP default timezone // Set PHP default timezone
+1 -1
View File
@@ -142,7 +142,7 @@ class DashboardController {
if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo; if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo;
// Get tickets with pagination, sorting, search, and advanced filters // Get tickets with pagination, sorting, search, and advanced filters
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters); $result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters, $GLOBALS['currentUser'] ?? []);
// Get categories and types for filters (single query) // Get categories and types for filters (single query)
$filterOptions = $this->getCategoriesAndTypes(); $filterOptions = $this->getCategoriesAndTypes();
+27 -61
View File
@@ -49,8 +49,10 @@ class TicketController {
return; return;
} }
// Get comments for this ticket using CommentModel // Load first page of comments; show "load more" if ticket has many
$comments = $this->commentModel->getCommentsByTicketId($id); $commentPageSize = 50;
$totalComments = $this->commentModel->getCommentCount((int)$id);
$comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0);
// Get timeline for this ticket // Get timeline for this ticket
$timeline = $this->auditLogModel->getTicketTimeline($id); $timeline = $this->auditLogModel->getTicketTimeline($id);
@@ -75,6 +77,18 @@ class TicketController {
// Check if form was submitted // Check if form was submitted
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate CSRF token
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_POST['csrf_token'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
$error = "Invalid or expired security token. Please try again.";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn;
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
// Handle visibility groups (comes as array from checkboxes) // Handle visibility groups (comes as array from checkboxes)
$visibilityGroups = null; $visibilityGroups = null;
if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) { if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
@@ -111,6 +125,17 @@ class TicketController {
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData); $GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
} }
// Auto-link as duplicate if requested from create form
$linkDupOf = isset($_POST['link_duplicate_of']) ? (int)$_POST['link_duplicate_of'] : 0;
if ($linkDupOf > 0) {
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_ticket_id, dependency_type, created_by)
VALUES (?, ?, 'duplicates', ?)";
$depStmt = $this->conn->prepare($depSql);
$depStmt->bind_param("iii", $result['ticket_id'], $linkDupOf, $userId);
$depStmt->execute();
$depStmt->close();
}
// Send Matrix notification for new ticket // Send Matrix notification for new ticket
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual'); NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
@@ -136,65 +161,6 @@ class TicketController {
include dirname(__DIR__) . '/views/CreateTicketView.php'; include dirname(__DIR__) . '/views/CreateTicketView.php';
} }
} }
public function update($id) {
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// Check if this is an AJAX request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// For AJAX requests, get JSON data
$input = file_get_contents('php://input');
$data = json_decode($input, true);
// Add ticket_id to the data
$data['ticket_id'] = $id;
// Validate input data
if (empty($data['title'])) {
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'Title cannot be empty'
]);
return;
}
// Update ticket with user tracking
// Pass expected_updated_at for optimistic locking if provided
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
$result = $this->ticketModel->updateTicket($data, $userId, $expectedUpdatedAt);
// Log ticket update to audit log
if ($result['success'] && isset($GLOBALS['auditLog']) && $userId) {
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
}
// Return JSON response
header('Content-Type: application/json');
if ($result['success']) {
echo json_encode([
'success' => true,
'status' => $data['status']
]);
} else {
$response = [
'success' => false,
'error' => $result['error'] ?? 'Failed to update ticket'
];
if (!empty($result['conflict'])) {
$response['conflict'] = true;
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
}
echo json_encode($response);
}
} else {
// For direct access, redirect to view
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/$id");
exit;
}
}
} }
?> ?>
+199 -31
View File
@@ -1,41 +1,17 @@
<?php <?php
require_once dirname(__DIR__) . '/helpers/UrlHelper.php'; require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
class NotificationHelper { class NotificationHelper {
/**
* Send a Matrix webhook notification for a new ticket. // ─── Internal: fire a webhook ─────────────────────────────────────────────
*
* @param string $ticketId Ticket ID (9-digit string) private static function fire(array $payload): void {
* @param array $ticketData Ticket fields (title, priority, category, type, status, ...)
* @param string $trigger 'manual' (web UI) or 'automated' (API)
*/
public static function sendTicketNotification($ticketId, $ticketData, $trigger = 'manual') {
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null; $webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
if (empty($webhookUrl)) { if (empty($webhookUrl)) {
return; return;
} }
// Parse notify users from config (comma-separated Matrix user IDs)
$notifyRaw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
$notifyUsers = array_values(array_filter(array_map('trim', explode(',', $notifyRaw))));
// Extract hostname from [hostname] prefix in title
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
$payload = [
'ticket_id' => $ticketId,
'title' => $ticketData['title'] ?? 'Untitled',
'priority' => (int)($ticketData['priority'] ?? 4),
'category' => $ticketData['category'] ?? 'General',
'type' => $ticketData['type'] ?? 'Issue',
'status' => $ticketData['status'] ?? 'Open',
'source' => $source,
'url' => UrlHelper::ticketUrl($ticketId),
'trigger' => $trigger,
'notify_users' => $notifyUsers,
];
$ch = curl_init($webhookUrl); $ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POST, 1);
@@ -48,11 +24,203 @@ class NotificationHelper {
$curlError = curl_error($ch); $curlError = curl_error($ch);
curl_close($ch); curl_close($ch);
$id = $payload['ticket_id'] ?? '?';
if ($curlError) { if ($curlError) {
error_log("Matrix webhook cURL error for ticket #{$ticketId}: {$curlError}"); error_log("Matrix webhook cURL error for ticket #{$id}: {$curlError}");
} elseif ($httpCode < 200 || $httpCode >= 300) { } elseif ($httpCode < 200 || $httpCode >= 300) {
error_log("Matrix webhook failed for ticket #{$ticketId}. HTTP {$httpCode}: {$response}"); error_log("Matrix webhook failed for ticket #{$id}. HTTP {$httpCode}: {$response}");
} }
} }
private static function notifyUsers(): array {
$raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
return array_values(array_filter(array_map('trim', explode(',', $raw))));
}
// ─── Public event methods ─────────────────────────────────────────────────
/**
* New ticket created (manual or automated/API).
*/
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void {
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
self::fire([
'event' => 'ticket_created',
'ticket_id' => $ticketId,
'title' => $ticketData['title'] ?? 'Untitled',
'priority' => (int)($ticketData['priority'] ?? 4),
'category' => $ticketData['category'] ?? 'General',
'type' => $ticketData['type'] ?? 'Issue',
'status' => $ticketData['status'] ?? 'Open',
'source' => $source,
'url' => UrlHelper::ticketUrl($ticketId),
'trigger' => $trigger,
'notify_users' => self::notifyUsers(),
]);
}
/**
* Ticket status changed.
*
* @param string|int $ticketId
* @param string $oldStatus
* @param string $newStatus
* @param string $ticketTitle
* @param string|null $changedByDisplay Display name of the user who changed status
*/
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void {
self::fire([
'event' => 'status_changed',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'old_status' => $oldStatus,
'new_status' => $newStatus,
'changed_by' => $changedByDisplay,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => self::notifyUsers(),
]);
}
/**
* New comment posted (non-mention; use sendMentionNotification for @mentions).
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string $commentText Plain text (first 200 chars will be sent)
* @param string|null $authorDisplay Display name of commenter
* @param bool $isInternal True if the comment is internal-only
*/
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void {
// Skip if this is an internal-only comment — only the assignee/admin need to know
$notifyUsers = self::notifyUsers();
if (empty($notifyUsers)) {
return;
}
self::fire([
'event' => 'comment_added',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'author' => $authorDisplay,
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
'is_internal' => $isInternal,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $notifyUsers,
]);
}
/**
* @mention detected in a comment.
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string $commentText
* @param string|null $authorDisplay
* @param array $mentionedMatrixIds Matrix user IDs derived from @usernames
*/
public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void {
if (empty($mentionedMatrixIds)) {
return;
}
self::fire([
'event' => 'mention',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'author' => $authorDisplay,
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $mentionedMatrixIds,
]);
}
/**
* Notify all watchers of a ticket about an update event.
*
* Fetches watchers from the DB, resolves their Matrix IDs via Synapse,
* and fires the appropriate event notification with them in notify_users.
*
* @param \mysqli $conn
* @param string|int $ticketId
* @param string $ticketTitle
* @param string $event One of: status_changed, comment_added, assigned
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
* @param int|null $excludeUserId Don't notify the actor themselves
*/
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null): void {
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
if (!$webhookUrl || !$domain) {
return;
}
// Fetch watcher usernames
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("i", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$stmt->close();
$usernames = [];
while ($row = $result->fetch_assoc()) {
$usernames[] = $row['username'];
}
if (empty($usernames)) {
return;
}
// Resolve to Matrix IDs — skip users without Synapse accounts
$matrixIds = SynapseHelper::resolveUsernames($usernames);
if (empty($matrixIds)) {
return;
}
// Remove the global notify list duplicates and build payload
$allNotify = array_unique(array_merge($matrixIds, self::notifyUsers()));
$payload = array_merge($extraData, [
'event' => $event,
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => array_values($allNotify),
]);
self::fire($payload);
}
/**
* Ticket assigned (or reassigned) to a user.
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string|null $assigneeName Display name of new assignee
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
* @param string|null $changedByDisplay
*/
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void {
$notifyUsers = self::notifyUsers();
// Also notify the assignee directly if we know their Matrix ID
if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) {
$notifyUsers[] = $assigneeMatrix;
}
if (empty($notifyUsers)) {
return;
}
self::fire([
'event' => 'assigned',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'assignee' => $assigneeName,
'changed_by' => $changedByDisplay,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $notifyUsers,
]);
}
} }
?> ?>
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* SynapseHelper
*
* Resolves local (SSO) usernames → Matrix user IDs by querying the
* Synapse Admin REST API directly. No caching — every call is live
* so results never go stale.
*
* Required config (.env) keys:
* MATRIX_DOMAIN e.g. matrix.lotusguild.org
* SYNAPSE_ADMIN_URL e.g. http://10.10.10.29:8008 (internal client-API URL)
* SYNAPSE_ADMIN_TOKEN a Synapse admin access token
*/
class SynapseHelper {
/**
* Resolve a local SSO username to its Matrix user ID.
*
* Uses the Synapse Admin API v2 endpoint:
* GET /_synapse/admin/v2/users/@{username}:{domain}
*
* If the account exists in Synapse the method returns the Matrix ID string.
* If the account does not exist, or if Synapse is unreachable / not configured,
* it returns null silently (notifications are best-effort).
*
* @param string $username Local username (e.g. "jared")
* @return string|null Matrix user ID (e.g. "@jared:matrix.lotusguild.org") or null
*/
public static function resolveUsername(string $username): ?string {
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
if (!$baseUrl || !$token || !$domain) {
return null;
}
$matrixId = '@' . rawurlencode($username) . ':' . $domain;
$url = rtrim($baseUrl, '/') . '/_synapse/admin/v2/users/' . rawurlencode($matrixId);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $token,
'Accept: application/json',
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
error_log("SynapseHelper: cURL error resolving '{$username}': {$curlError}");
return null;
}
if ($httpCode === 200) {
$data = json_decode($body, true);
// Confirm the response contains the name we expect
if (!empty($data['name'])) {
return $data['name']; // e.g. "@jared:matrix.lotusguild.org"
}
}
// 404 = user not found in Synapse; other codes = error
if ($httpCode !== 404) {
error_log("SynapseHelper: unexpected HTTP {$httpCode} resolving '{$username}'");
}
return null;
}
/**
* Resolve multiple usernames to Matrix IDs.
* Returns only those that were successfully confirmed in Synapse.
*
* @param string[] $usernames
* @return string[] Matrix user IDs
*/
public static function resolveUsernames(array $usernames): array {
$ids = [];
foreach ($usernames as $username) {
$id = self::resolveUsername($username);
if ($id !== null) {
$ids[] = $id;
}
}
return $ids;
}
}
?>
+32 -42
View File
@@ -53,6 +53,15 @@ if (!str_starts_with($requestPath, '/api/')) {
} }
} }
// Helper: require admin or render styled 403 and exit
function requireAdmin(?array $user): void {
if (!$user || empty($user['is_admin'])) {
http_response_code(403);
include __DIR__ . '/views/error_403.php';
exit;
}
}
// Simple router // Simple router
switch (true) { switch (true) {
case $requestPath == '/' || $requestPath == '': case $requestPath == '/' || $requestPath == '':
@@ -106,6 +115,14 @@ switch (true) {
require_once 'api/get_users.php'; require_once 'api/get_users.php';
break; break;
case $requestPath == '/api/get_comments.php':
require_once 'api/get_comments.php';
break;
case $requestPath == '/api/watch_ticket.php':
require_once 'api/watch_ticket.php';
break;
case $requestPath == '/api/assign_ticket.php': case $requestPath == '/api/assign_ticket.php':
require_once 'api/assign_ticket.php'; require_once 'api/assign_ticket.php';
break; break;
@@ -176,11 +193,7 @@ switch (true) {
// Admin Routes - require admin privileges // Admin Routes - require admin privileges
case $requestPath == '/admin/recurring-tickets': case $requestPath == '/admin/recurring-tickets':
if (!$currentUser || !$currentUser['is_admin']) { requireAdmin($currentUser);
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
require_once 'models/RecurringTicketModel.php'; require_once 'models/RecurringTicketModel.php';
$recurringModel = new RecurringTicketModel($conn); $recurringModel = new RecurringTicketModel($conn);
$recurringTickets = $recurringModel->getAll(true); $recurringTickets = $recurringModel->getAll(true);
@@ -188,11 +201,7 @@ switch (true) {
break; break;
case $requestPath == '/admin/custom-fields': case $requestPath == '/admin/custom-fields':
if (!$currentUser || !$currentUser['is_admin']) { requireAdmin($currentUser);
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
require_once 'models/CustomFieldModel.php'; require_once 'models/CustomFieldModel.php';
$fieldModel = new CustomFieldModel($conn); $fieldModel = new CustomFieldModel($conn);
$customFields = $fieldModel->getAllDefinitions(null, false); $customFields = $fieldModel->getAllDefinitions(null, false);
@@ -200,11 +209,7 @@ switch (true) {
break; break;
case $requestPath == '/admin/workflow': case $requestPath == '/admin/workflow':
if (!$currentUser || !$currentUser['is_admin']) { requireAdmin($currentUser);
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status"); $result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
$workflows = []; $workflows = [];
while ($row = $result->fetch_assoc()) { while ($row = $result->fetch_assoc()) {
@@ -214,11 +219,7 @@ switch (true) {
break; break;
case $requestPath == '/admin/templates': case $requestPath == '/admin/templates':
if (!$currentUser || !$currentUser['is_admin']) { requireAdmin($currentUser);
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name"); $result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
$templates = []; $templates = [];
while ($row = $result->fetch_assoc()) { while ($row = $result->fetch_assoc()) {
@@ -228,11 +229,7 @@ switch (true) {
break; break;
case $requestPath == '/admin/audit-log': case $requestPath == '/admin/audit-log':
if (!$currentUser || !$currentUser['is_admin']) { requireAdmin($currentUser);
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1; $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$perPage = 50; $perPage = 50;
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
@@ -242,7 +239,9 @@ switch (true) {
$params = []; $params = [];
$types = ''; $types = '';
if (!empty($_GET['action_type'])) { $allowedActionTypes = ['create','update','delete','comment','assign','status_change','login','security',
'ticket_create','ticket_update','ticket_delete','attachment_delete','attachment_upload'];
if (!empty($_GET['action_type']) && in_array($_GET['action_type'], $allowedActionTypes, true)) {
$whereConditions[] = "al.action_type = ?"; $whereConditions[] = "al.action_type = ?";
$params[] = $_GET['action_type']; $params[] = $_GET['action_type'];
$types .= 's'; $types .= 's';
@@ -252,15 +251,15 @@ switch (true) {
$whereConditions[] = "al.user_id = ?"; $whereConditions[] = "al.user_id = ?";
$params[] = (int)$_GET['user_id']; $params[] = (int)$_GET['user_id'];
$types .= 'i'; $types .= 'i';
$filters['user_id'] = $_GET['user_id']; $filters['user_id'] = (int)$_GET['user_id'];
} }
if (!empty($_GET['date_from'])) { if (!empty($_GET['date_from']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_from'])) {
$whereConditions[] = "DATE(al.created_at) >= ?"; $whereConditions[] = "DATE(al.created_at) >= ?";
$params[] = $_GET['date_from']; $params[] = $_GET['date_from'];
$types .= 's'; $types .= 's';
$filters['date_from'] = $_GET['date_from']; $filters['date_from'] = $_GET['date_from'];
} }
if (!empty($_GET['date_to'])) { if (!empty($_GET['date_to']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_to'])) {
$whereConditions[] = "DATE(al.created_at) <= ?"; $whereConditions[] = "DATE(al.created_at) <= ?";
$params[] = $_GET['date_to']; $params[] = $_GET['date_to'];
$types .= 's'; $types .= 's';
@@ -312,11 +311,7 @@ switch (true) {
break; break;
case $requestPath == '/admin/api-keys': case $requestPath == '/admin/api-keys':
if (!$currentUser || !$currentUser['is_admin']) { requireAdmin($currentUser);
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
require_once 'models/ApiKeyModel.php'; require_once 'models/ApiKeyModel.php';
$apiKeyModel = new ApiKeyModel($conn); $apiKeyModel = new ApiKeyModel($conn);
$apiKeys = $apiKeyModel->getAllKeys(); $apiKeys = $apiKeyModel->getAllKeys();
@@ -324,11 +319,7 @@ switch (true) {
break; break;
case $requestPath == '/admin/user-activity': case $requestPath == '/admin/user-activity':
if (!$currentUser || !$currentUser['is_admin']) { requireAdmin($currentUser);
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
$dateRange = [ $dateRange = [
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')), 'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
@@ -405,9 +396,8 @@ switch (true) {
exit; exit;
default: default:
// 404 Not Found http_response_code(404);
header("HTTP/1.0 404 Not Found"); include __DIR__ . '/views/error_404.php';
echo '404 Page Not Found';
break; break;
} }
+5 -1
View File
@@ -155,7 +155,11 @@ class AuthMiddleware {
} }
// Check for admin or employee group membership // Check for admin or employee group membership
$userGroups = array_map('trim', explode(',', strtolower($groups))); // Filter to safe characters only to prevent header injection attacks
$userGroups = array_filter(
array_map('trim', explode(',', strtolower($groups))),
function($g) { return preg_match('/^[a-z0-9_\-]+$/', $g); }
);
$requiredGroups = ['admin', 'employee']; $requiredGroups = ['admin', 'employee'];
return !empty(array_intersect($userGroups, $requiredGroups)); return !empty(array_intersect($userGroups, $requiredGroups));
+12
View File
@@ -44,6 +44,18 @@ class CsrfMiddleware {
return hash_equals($_SESSION[self::$tokenName], $token); return hash_equals($_SESSION[self::$tokenName], $token);
} }
/**
* Rotate the CSRF token after a successful validated POST.
* Call this after validateToken() returns true, then include
* the new token in the JSON response as 'csrf_token' so the
* client can update window.CSRF_TOKEN for subsequent requests.
*
* @return string The new token
*/
public static function rotateToken(): string {
return self::generateToken();
}
/** /**
* Check if token is expired * Check if token is expired
*/ */
+1 -1
View File
@@ -64,7 +64,7 @@ class RateLimitMiddleware {
$now = time(); $now = time();
// Create a hash of the IP for the filename (security + filesystem safety) // Create a hash of the IP for the filename (security + filesystem safety)
$ipHash = md5($ip . '_' . $type); $ipHash = hash('sha256', $ip . '_' . $type);
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json'; $filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
// Load existing rate data // Load existing rate data
+1 -1
View File
@@ -28,7 +28,7 @@ class SecurityHeadersMiddleware {
// Content Security Policy - restricts where resources can be loaded from // Content Security Policy - restricts where resources can be loaded from
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces // Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
// All inline event handlers have been refactored to use addEventListener with data-action attributes // All inline event handlers have been refactored to use addEventListener with data-action attributes
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';"); header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';");
// Prevent clickjacking by disallowing framing // Prevent clickjacking by disallowing framing
header("X-Frame-Options: DENY"); header("X-Frame-Options: DENY");
-48
View File
@@ -1,48 +0,0 @@
-- Migration: Add Performance Indexes
-- Run this migration to improve query performance on common operations
-- Single-column indexes for filtering
-- These support the most common WHERE clauses in getAllTickets()
-- Status filtering (very common - used in almost every query)
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
-- Category and type filtering
CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category);
CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(type);
-- Priority filtering
CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority);
-- Date-based filtering and sorting
CREATE INDEX IF NOT EXISTS idx_tickets_created_at ON tickets(created_at);
CREATE INDEX IF NOT EXISTS idx_tickets_updated_at ON tickets(updated_at);
-- User filtering
CREATE INDEX IF NOT EXISTS idx_tickets_created_by ON tickets(created_by);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to ON tickets(assigned_to);
-- Visibility filtering (used in every authenticated query)
CREATE INDEX IF NOT EXISTS idx_tickets_visibility ON tickets(visibility);
-- Composite indexes for common query patterns
-- These are more efficient than single indexes for combined filters
-- Status + created_at (common sorting with status filter)
CREATE INDEX IF NOT EXISTS idx_tickets_status_created ON tickets(status, created_at);
-- Assigned_to + status (for "my open tickets" queries)
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_status ON tickets(assigned_to, status);
-- Visibility + status (visibility filtering with status)
CREATE INDEX IF NOT EXISTS idx_tickets_visibility_status ON tickets(visibility, status);
-- ticket_comments table
-- Optimize comment retrieval by ticket
CREATE INDEX IF NOT EXISTS idx_comments_ticket_created ON ticket_comments(ticket_id, created_at);
-- Audit log indexes (if audit_log table exists)
-- Optimize audit log queries
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, created_at);
-19
View File
@@ -1,19 +0,0 @@
-- Migration: Add comment threading support
-- Adds parent_comment_id for reply/thread functionality
-- Add parent_comment_id column for threaded comments
ALTER TABLE ticket_comments
ADD COLUMN parent_comment_id INT NULL DEFAULT NULL AFTER comment_id;
-- Add foreign key constraint (self-referencing for thread hierarchy)
ALTER TABLE ticket_comments
ADD CONSTRAINT fk_parent_comment
FOREIGN KEY (parent_comment_id) REFERENCES ticket_comments(comment_id)
ON DELETE CASCADE;
-- Add index for efficient thread retrieval
CREATE INDEX idx_parent_comment ON ticket_comments(parent_comment_id);
-- Add thread_depth column to track nesting level (prevents infinite recursion issues)
ALTER TABLE ticket_comments
ADD COLUMN thread_depth TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER parent_comment_id;
+5 -5
View File
@@ -21,7 +21,7 @@ class AttachmentModel {
ORDER BY a.uploaded_at DESC"; ORDER BY a.uploaded_at DESC";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId); $stmt->bind_param("i", $ticketId);
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();
@@ -61,7 +61,7 @@ class AttachmentModel {
VALUES (?, ?, ?, ?, ?, ?)"; VALUES (?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy); $stmt->bind_param("issisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
$result = $stmt->execute(); $result = $stmt->execute();
if ($result) { if ($result) {
@@ -97,7 +97,7 @@ class AttachmentModel {
WHERE ticket_id = ?"; WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId); $stmt->bind_param("i", $ticketId);
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();
$row = $result->fetch_assoc(); $row = $result->fetch_assoc();
@@ -113,7 +113,7 @@ class AttachmentModel {
$sql = "SELECT COUNT(*) as count FROM ticket_attachments WHERE ticket_id = ?"; $sql = "SELECT COUNT(*) as count FROM ticket_attachments WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId); $stmt->bind_param("i", $ticketId);
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();
$row = $result->fetch_assoc(); $row = $result->fetch_assoc();
@@ -131,7 +131,7 @@ class AttachmentModel {
} }
$attachment = $this->getAttachment($attachmentId); $attachment = $this->getAttachment($attachmentId);
return $attachment && $attachment['uploaded_by'] == $userId; return $attachment && (int)$attachment['uploaded_by'] === (int)$userId;
} }
/** /**
+105 -11
View File
@@ -50,10 +50,35 @@ class CommentModel {
return $users; return $users;
} }
public function getCommentsByTicketId($ticketId, $threaded = true) { /**
// Check if threading columns exist * Get total comment count for a ticket
*/
public function getCommentCount(int $ticketId): int {
$stmt = $this->conn->prepare(
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
);
$stmt->bind_param("i", $ticketId);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
return (int)($row['total'] ?? 0);
}
/**
* @param int $ticketId
* @param bool $threaded Build nested reply structure (threading)
* @param int $limit Max root-level comments to return (0 = all)
* @param int $offset Root-level comment offset for pagination
*/
public function getCommentsByTicketId($ticketId, $threaded = true, int $limit = 0, int $offset = 0) {
$hasThreading = $this->hasThreadingSupport(); $hasThreading = $this->hasThreadingSupport();
// When paginating with threading we fetch root comments page first,
// then pull all their replies in a second query.
if ($hasThreading && $threaded && $limit > 0) {
return $this->getThreadedCommentsPaged($ticketId, $limit, $offset);
}
if ($hasThreading) { if ($hasThreading) {
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth $sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
FROM ticket_comments tc FROM ticket_comments tc
@@ -70,16 +95,21 @@ class CommentModel {
ORDER BY tc.created_at DESC"; ORDER BY tc.created_at DESC";
} }
if ($limit > 0) {
$sql .= " LIMIT ? OFFSET ?";
}
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $ticketId); if ($limit > 0) {
$stmt->bind_param("iii", $ticketId, $limit, $offset);
} else {
$stmt->bind_param("i", $ticketId);
}
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();
$comments = [];
$commentMap = []; $commentMap = [];
while ($row = $result->fetch_assoc()) { while ($row = $result->fetch_assoc()) {
// Use display_name from users table if available, fallback to user_name field
if (!empty($row['display_name'])) { if (!empty($row['display_name'])) {
$row['display_name_formatted'] = $row['display_name']; $row['display_name_formatted'] = $row['display_name'];
} else { } else {
@@ -90,8 +120,9 @@ class CommentModel {
$row['thread_depth'] = $row['thread_depth'] ?? 0; $row['thread_depth'] = $row['thread_depth'] ?? 0;
$commentMap[$row['comment_id']] = $row; $commentMap[$row['comment_id']] = $row;
} }
$stmt->close();
// Build threaded structure if threading is enabled // Build threaded structure if threading is enabled (no pagination — all loaded)
if ($hasThreading && $threaded) { if ($hasThreading && $threaded) {
$rootComments = []; $rootComments = [];
foreach ($commentMap as $id => $comment) { foreach ($commentMap as $id => $comment) {
@@ -102,10 +133,73 @@ class CommentModel {
return $rootComments; return $rootComments;
} }
// Flat list
return array_values($commentMap); return array_values($commentMap);
} }
/**
* Paginated threaded comments: fetch one page of root comments + all their replies.
*/
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array {
// Page of root comments
$rootSql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ? AND tc.parent_comment_id IS NULL
ORDER BY tc.created_at DESC
LIMIT ? OFFSET ?";
$stmt = $this->conn->prepare($rootSql);
$stmt->bind_param("iii", $ticketId, $limit, $offset);
$stmt->execute();
$rootResult = $stmt->get_result();
$stmt->close();
$commentMap = [];
$rootIds = [];
while ($row = $rootResult->fetch_assoc()) {
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
$row['replies'] = [];
$row['parent_comment_id'] = null;
$row['thread_depth'] = 0;
$commentMap[$row['comment_id']] = $row;
$rootIds[] = $row['comment_id'];
}
if (empty($rootIds)) {
return [];
}
// All replies for these root comments (up to 3 levels deep)
$placeholders = implode(',', array_fill(0, count($rootIds), '?'));
$replySql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ?
AND tc.parent_comment_id IN ($placeholders)
AND tc.parent_comment_id IS NOT NULL
ORDER BY tc.created_at ASC";
$replyStmt = $this->conn->prepare($replySql);
$types = 'i' . str_repeat('i', count($rootIds));
$replyStmt->bind_param($types, $ticketId, ...$rootIds);
$replyStmt->execute();
$replyResult = $replyStmt->get_result();
$replyStmt->close();
while ($row = $replyResult->fetch_assoc()) {
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
$row['replies'] = [];
$row['thread_depth'] = $row['thread_depth'] ?? 1;
$commentMap[$row['comment_id']] = $row;
}
$rootComments = [];
foreach ($rootIds as $rid) {
if (isset($commentMap[$rid])) {
$rootComments[] = $this->buildCommentThread($commentMap[$rid], $commentMap);
}
}
return $rootComments;
}
/** /**
* Check if threading columns exist * Check if threading columns exist
*/ */
@@ -126,7 +220,7 @@ class CommentModel {
private function buildCommentThread($comment, &$allComments) { private function buildCommentThread($comment, &$allComments) {
$comment['replies'] = []; $comment['replies'] = [];
foreach ($allComments as $c) { foreach ($allComments as $c) {
if ($c['parent_comment_id'] == $comment['comment_id'] if ((int)$c['parent_comment_id'] === (int)$comment['comment_id']
&& isset($allComments[$c['comment_id']])) { && isset($allComments[$c['comment_id']])) {
$comment['replies'][] = $this->buildCommentThread($c, $allComments); $comment['replies'][] = $this->buildCommentThread($c, $allComments);
} }
@@ -240,7 +334,7 @@ class CommentModel {
return ['success' => false, 'error' => 'Comment not found']; return ['success' => false, 'error' => 'Comment not found'];
} }
if ($comment['user_id'] != $userId && !$isAdmin) { if ($comment['user_id'] !== (int)$userId && !$isAdmin) {
return ['success' => false, 'error' => 'You do not have permission to edit this comment']; return ['success' => false, 'error' => 'You do not have permission to edit this comment'];
} }
@@ -286,7 +380,7 @@ class CommentModel {
return ['success' => false, 'error' => 'Comment not found']; return ['success' => false, 'error' => 'Comment not found'];
} }
if ($comment['user_id'] != $userId && !$isAdmin) { if ($comment['user_id'] !== (int)$userId && !$isAdmin) {
return ['success' => false, 'error' => 'You do not have permission to delete this comment']; return ['success' => false, 'error' => 'You do not have permission to delete this comment'];
} }
+1 -1
View File
@@ -128,7 +128,7 @@ class CustomFieldModel {
WHERE field_id = ?"; WHERE field_id = ?";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param('sssssiiiii', $stmt->bind_param('sssssiiii',
$data['field_name'], $data['field_name'],
$data['field_label'], $data['field_label'],
$data['field_type'], $data['field_type'],
+4 -128
View File
@@ -22,110 +22,6 @@ class StatsModel {
$this->conn = $conn; $this->conn = $conn;
} }
/**
* Get count of open tickets
*/
public function getOpenTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status IN ('Open', 'Pending', 'In Progress')";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of closed tickets
*/
public function getClosedTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get tickets grouped by priority
*/
public function getTicketsByPriority(): array {
$sql = "SELECT priority, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY priority ORDER BY priority";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data['P' . $row['priority']] = (int)$row['count'];
}
return $data;
}
/**
* Get tickets grouped by status
*/
public function getTicketsByStatus(): array {
$sql = "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY FIELD(status, 'Open', 'Pending', 'In Progress', 'Closed')";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data[$row['status']] = (int)$row['count'];
}
return $data;
}
/**
* Get tickets grouped by category
*/
public function getTicketsByCategory(): array {
$sql = "SELECT category, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY category ORDER BY count DESC";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data[$row['category']] = (int)$row['count'];
}
return $data;
}
/**
* Get average resolution time in hours
*/
public function getAverageResolutionTime(): float {
$sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, closed_at)) as avg_hours
FROM tickets
WHERE status = 'Closed'
AND created_at IS NOT NULL
AND closed_at IS NOT NULL
AND closed_at > created_at";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return $row['avg_hours'] ? round($row['avg_hours'], 1) : 0;
}
/**
* Get count of tickets created today
*/
public function getTicketsCreatedToday(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE DATE(created_at) = CURDATE()";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of tickets created this week
*/
public function getTicketsCreatedThisWeek(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1)";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of tickets closed today
*/
public function getTicketsClosedToday(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(closed_at) = CURDATE()";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/** /**
* Get tickets by assignee (top 5) * Get tickets by assignee (top 5)
*/ */
@@ -153,26 +49,6 @@ class StatsModel {
return $data; return $data;
} }
/**
* Get unassigned ticket count
*/
public function getUnassignedTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE assigned_to IS NULL AND status != 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get critical (P1) ticket count
*/
public function getCriticalTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE priority = 1 AND status != 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/** /**
* Get all stats as a single array, respecting ticket visibility for the given user. * Get all stats as a single array, respecting ticket visibility for the given user.
* *
@@ -228,7 +104,7 @@ class StatsModel {
SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical, SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
AVG(CASE WHEN status = 'Closed' AND closed_at > created_at AVG(CASE WHEN status = 'Closed' AND closed_at > created_at
THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution
FROM tickets WHERE ($visSQL)"; FROM tickets t WHERE ($visSQL)";
if (!empty($visParams)) { if (!empty($visParams)) {
$stmt = $this->conn->prepare($countsSql); $stmt = $this->conn->prepare($countsSql);
@@ -244,13 +120,13 @@ class StatsModel {
// Query 2: Get priority, status, and category breakdowns in one query // Query 2: Get priority, status, and category breakdowns in one query
$breakdownSql = "SELECT $breakdownSql = "SELECT
'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count 'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count
FROM tickets WHERE status != 'Closed' AND ($visSQL) GROUP BY priority FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY priority
UNION ALL UNION ALL
SELECT 'status' as type, status as label, COUNT(*) as count SELECT 'status' as type, status as label, COUNT(*) as count
FROM tickets WHERE ($visSQL) GROUP BY status FROM tickets t WHERE ($visSQL) GROUP BY status
UNION ALL UNION ALL
SELECT 'category' as type, category as label, COUNT(*) as count SELECT 'category' as type, category as label, COUNT(*) as count
FROM tickets WHERE status != 'Closed' AND ($visSQL) GROUP BY category"; FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY category";
if (!empty($visParams)) { if (!empty($visParams)) {
// Need to bind params 3 times (once per UNION branch) // Need to bind params 3 times (once per UNION branch)
+1 -1
View File
@@ -87,7 +87,7 @@ class TemplateModel {
default_priority = ? default_priority = ?
WHERE template_id = ?"; WHERE template_id = ?";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("ssssiii", $stmt->bind_param("sssssii",
$data['template_name'], $data['template_name'],
$data['title_template'], $data['title_template'],
$data['description_template'], $data['description_template'],
+60 -31
View File
@@ -31,7 +31,7 @@ class TicketModel {
return $result->fetch_assoc(); return $result->fetch_assoc();
} }
public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = []): array { public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = [], ?array $user = null): array {
// Calculate offset // Calculate offset
$offset = ($page - 1) * $limit; $offset = ($page - 1) * $limit;
@@ -40,6 +40,16 @@ class TicketModel {
$params = []; $params = [];
$paramTypes = ''; $paramTypes = '';
// Visibility filtering
if ($user !== null) {
$visFilter = $this->getVisibilityFilter($user);
if ($visFilter['sql'] !== '1=1') {
$whereConditions[] = $visFilter['sql'];
$params = array_merge($params, $visFilter['params']);
$paramTypes .= $visFilter['types'];
}
}
// Status filtering // Status filtering
if ($status) { if ($status) {
$statuses = explode(',', $status); $statuses = explode(',', $status);
@@ -67,12 +77,20 @@ class TicketModel {
$paramTypes .= str_repeat('s', count($types)); $paramTypes .= str_repeat('s', count($types));
} }
// Search Functionality // Search Functionality — use FULLTEXT when available, fall back to LIKE
if ($search && !empty($search)) { if ($search && !empty($search)) {
$whereConditions[] = "(title LIKE ? OR description LIKE ? OR ticket_id LIKE ? OR category LIKE ? OR type LIKE ?)"; if ($this->hasFulltextIndex()) {
$searchTerm = "%$search%"; // MATCH...AGAINST for indexed full-text search (much faster at scale)
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]); $whereConditions[] = "(MATCH(t.title, t.description) AGAINST (? IN BOOLEAN MODE) OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
$paramTypes .= 'sssss'; $searchTerm = "%$search%";
$params = array_merge($params, [$search . '*', $searchTerm, $searchTerm, $searchTerm]);
$paramTypes .= 'ssss';
} else {
$whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
$searchTerm = "%$search%";
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
$paramTypes .= 'sssss';
}
} }
// Advanced search filters // Advanced search filters
@@ -156,53 +174,44 @@ class TicketModel {
// Validate sort direction // Validate sort direction
$sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC'; $sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC';
// Get total count for pagination // Single query: use COUNT(*) OVER() window function to get total + page in one pass
$countSql = "SELECT COUNT(*) as total FROM tickets t $whereClause";
$countStmt = $this->conn->prepare($countSql);
if (!empty($params)) {
$countStmt->bind_param($paramTypes, ...$params);
}
$countStmt->execute();
$totalResult = $countStmt->get_result();
$totalTickets = $totalResult->fetch_assoc()['total'];
// Get tickets with pagination and creator info
$sql = "SELECT t.*, $sql = "SELECT t.*,
u_created.username as creator_username, u_created.username as creator_username,
u_created.display_name as creator_display_name, u_created.display_name as creator_display_name,
u_assigned.username as assigned_username, u_assigned.username as assigned_username,
u_assigned.display_name as assigned_display_name u_assigned.display_name as assigned_display_name,
COUNT(*) OVER() as _total_count
FROM tickets t FROM tickets t
LEFT JOIN users u_created ON t.created_by = u_created.user_id LEFT JOIN users u_created ON t.created_by = u_created.user_id
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
$whereClause $whereClause
ORDER BY $sortExpression $sortDirection ORDER BY $sortExpression $sortDirection
LIMIT ? OFFSET ?"; LIMIT ? OFFSET ?";
$stmt = $this->conn->prepare($sql);
// Add limit and offset parameters
$params[] = $limit; $params[] = $limit;
$params[] = $offset; $params[] = $offset;
$paramTypes .= 'ii'; $paramTypes .= 'ii';
$stmt = $this->conn->prepare($sql);
if (!empty($params)) { if (!empty($params)) {
$stmt->bind_param($paramTypes, ...$params); $stmt->bind_param($paramTypes, ...$params);
} }
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();
$tickets = []; $tickets = [];
$totalTickets = 0;
while ($row = $result->fetch_assoc()) { while ($row = $result->fetch_assoc()) {
$totalTickets = (int)$row['_total_count'];
unset($row['_total_count']);
$tickets[] = $row; $tickets[] = $row;
} }
$stmt->close();
return [ return [
'tickets' => $tickets, 'tickets' => $tickets,
'total' => $totalTickets, 'total' => $totalTickets,
'pages' => ceil($totalTickets / $limit), 'pages' => $totalTickets > 0 ? ceil($totalTickets / $limit) : 0,
'current_page' => $page 'current_page' => $page
]; ];
} }
@@ -468,7 +477,7 @@ class TicketModel {
$markdownEnabled = $commentData['markdown_enabled'] ? 1 : 0; $markdownEnabled = $commentData['markdown_enabled'] ? 1 : 0;
$stmt->bind_param( $stmt->bind_param(
"sssi", "issi",
$ticketId, $ticketId,
$username, $username,
$commentData['comment_text'], $commentData['comment_text'],
@@ -591,7 +600,7 @@ class TicketModel {
// Confidential tickets: only creator, assignee, and admins // Confidential tickets: only creator, assignee, and admins
if ($visibility === 'confidential') { if ($visibility === 'confidential') {
$userId = $user['user_id'] ?? null; $userId = $user['user_id'] ?? null;
return ($ticket['created_by'] == $userId || $ticket['assigned_to'] == $userId); return ((int)$ticket['created_by'] === (int)$userId || (int)$ticket['assigned_to'] === (int)$userId);
} }
// Internal tickets: check if user is in any of the allowed groups // Internal tickets: check if user is in any of the allowed groups
@@ -691,4 +700,24 @@ class TicketModel {
$stmt->close(); $stmt->close();
return $result; return $result;
} }
/**
* Check whether the FULLTEXT index on tickets(title, description) exists.
* Result is cached for the process lifetime (static).
*/
private function hasFulltextIndex(): bool {
static $result = null;
if ($result !== null) {
return $result;
}
$r = $this->conn->query(
"SELECT COUNT(*) as cnt FROM information_schema.STATISTICS
WHERE table_schema = DATABASE()
AND table_name = 'tickets'
AND index_type = 'FULLTEXT'
AND index_name = 'ft_title_description'"
);
$result = $r && (int)$r->fetch_assoc()['cnt'] > 0;
return $result;
}
} }
+1 -1
View File
@@ -227,7 +227,7 @@ class UserModel {
* @return bool True if user is admin * @return bool True if user is admin
*/ */
public function isAdmin(array $user): bool { public function isAdmin(array $user): bool {
return isset($user['is_admin']) && $user['is_admin'] == 1; return isset($user['is_admin']) && (int)$user['is_admin'] === 1;
} }
/** /**
+346 -320
View File
@@ -1,358 +1,384 @@
<?php <?php
// This file contains the HTML template for creating a new ticket /**
* CreateTicketView.php — New ticket creation form, redesigned for TDS v1.2
* Variables: $templates (array), $allUsers (array), $error (string|null)
*/
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'New Ticket';
$activeNav = 'dashboard';
$pageStyles = ['/assets/css/dashboard.css?v=20260327', '/assets/css/ticket.css?v=20260327'];
$pageScripts = [
'/assets/js/keyboard-shortcuts.js?v=20260327',
];
include __DIR__ . '/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en"> <!-- Page header -->
<head> <div class="lt-page-header">
<meta charset="UTF-8"> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<title>Create New Ticket</title> <span class="lt-text-muted lt-text-xs">/</span>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <span class="lt-text-muted lt-text-xs">New Ticket</span>
<link rel="stylesheet" href="/assets/css/base.css"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <!-- ═══════════════════════════════════════════════════════════
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script> CREATE TICKET FORM
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script> ═══════════════════════════════════════════════════════════ -->
<script nonce="<?php echo $nonce; ?>"> <form method="POST"
// CSRF Token for AJAX requests action="<?= htmlspecialchars($GLOBALS['config']['BASE_URL'], ENT_QUOTES, 'UTF-8') ?>/ticket/create"
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; class="create-ticket-form"
</script> novalidate>
</head>
<body> <input type="hidden" name="csrf_token"
<div class="user-header"> value="<?= htmlspecialchars(CsrfMiddleware::getToken(), ENT_QUOTES, 'UTF-8') ?>">
<div class="user-header-left"> <input type="hidden" name="link_duplicate_of" id="linkDuplicateOf" value="">
<a href="/" class="back-link">[ &larr; DASHBOARD ]</a>
</div> <?php if (isset($error)): ?>
<div class="user-header-right"> <div class="lt-msg lt-msg-danger lt-mb-md" role="alert">
<?php if (isset($GLOBALS['currentUser'])): ?> <strong>Error:</strong> <?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span> </div>
<?php if ($GLOBALS['currentUser']['is_admin']): ?> <?php endif ?>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?> <!-- ── SECTION 1: Template ───────────────────────────────── -->
<?php endif; ?> <div class="lt-frame lt-mb-md">
</div> <span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Template (Optional)</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label" for="templateSelect">Use a Template</label>
<select id="templateSelect" class="lt-select" data-action="load-template">
<option value="">— No Template —</option>
<?php if (!empty($templates)): ?>
<?php foreach ($templates as $tpl): ?>
<option value="<?= (int)$tpl['template_id'] ?>">
<?= htmlspecialchars($tpl['template_name']) ?>
</option>
<?php endforeach ?>
<?php endif ?>
</select>
<p class="lt-form-hint">Selecting a template pre-fills the form fields.</p>
</div>
</div> </div>
</div>
<!-- OUTER FRAME: Create Ticket Form Container --> <!-- ── SECTION 2: Title ─────────────────────────────────── -->
<div class="ascii-frame-outer"> <div class="lt-frame lt-mb-md">
<span class="bottom-left-corner"></span> <span class="lt-frame-bl">╚</span><span class="lt-frame-br"></span>
<span class="bottom-right-corner">╝</span> <div class="lt-section-header">Title *</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label lt-sr-only" for="title">Ticket Title</label>
<input type="text"
id="title"
name="title"
class="lt-input"
required
autocomplete="off"
placeholder="Enter a clear, concise title for this ticket"
aria-required="true"
aria-describedby="duplicateWarning">
</div>
<!-- SECTION 1: Form Header --> <!-- Duplicate warning (shown by JS when similar tickets exist) -->
<div class="ascii-section-header">Create New Ticket</div> <div id="duplicateWarning" class="lt-msg lt-msg-warning is-hidden"
<div class="ascii-content"> role="alert" aria-live="polite" aria-atomic="true">
<div class="ascii-frame-inner"> <strong class="lt-text-amber">Possible Duplicates Found</strong>
<div class="ticket-header"> <div id="duplicatesList" aria-live="polite"></div>
<h2>New Ticket Form</h2> </div>
<p class="form-hint">
Complete the form below to create a new ticket
</p>
</div>
</div>
</div>
<?php if (isset($error)): ?>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- ERROR SECTION -->
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="error-message inline-error">
<strong>[ ! ] ERROR:</strong> <?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?>
</div>
</div>
</div>
<?php endif; ?>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<form method="POST" action="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="ticket-form">
<!-- SECTION 2: Template Selection -->
<div class="ascii-section-header">Template Selection</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="templateSelect">Use Template (Optional)</label>
<select id="templateSelect" class="editable" data-action="load-template">
<option value="">-- No Template --</option>
<?php if (isset($templates) && !empty($templates)): ?>
<?php foreach ($templates as $template): ?>
<option value="<?php echo $template['template_id']; ?>">
<?php echo htmlspecialchars($template['template_name']); ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
<p class="form-hint">
Select a template to auto-fill form fields
</p>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 3: Basic Information -->
<div class="ascii-section-header">Basic Information</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="title">Ticket Title *</label>
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
</div>
<!-- Duplicate Warning Area -->
<div id="duplicateWarning" class="inline-warning is-hidden" role="alert" aria-live="polite" aria-atomic="true">
<div class="text-amber fw-bold duplicate-heading">
Possible Duplicates Found
</div>
<div id="duplicatesList" aria-live="polite"></div>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 4: Ticket Metadata -->
<div class="ascii-section-header">Ticket Metadata</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group status-priority-row">
<div class="detail-quarter">
<label for="status">Status</label>
<select id="status" name="status" class="editable">
<option value="Open" selected>Open</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="detail-quarter">
<label for="priority">Priority</label>
<select id="priority" name="priority" class="editable">
<option value="1">P1 - Critical Impact</option>
<option value="2">P2 - High Impact</option>
<option value="3">P3 - Medium Impact</option>
<option value="4" selected>P4 - Low Impact</option>
</select>
</div>
<div class="detail-quarter">
<label for="category">Category</label>
<select id="category" name="category" class="editable">
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
<option value="General" selected>General</option>
</select>
</div>
<div class="detail-quarter">
<label for="type">Type</label>
<select id="type" name="type" class="editable">
<option value="Maintenance">Maintenance</option>
<option value="Install">Install</option>
<option value="Task">Task</option>
<option value="Upgrade">Upgrade</option>
<option value="Issue" selected>Issue</option>
</select>
</div>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 4b: Assignment -->
<div class="ascii-section-header">Assignment</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="assigned_to">Assign To (Optional)</label>
<select id="assigned_to" name="assigned_to" class="editable">
<option value="">-- Unassigned --</option>
<?php if (isset($allUsers) && !empty($allUsers)): ?>
<?php foreach ($allUsers as $user): ?>
<option value="<?php echo $user['user_id']; ?>">
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
<p class="form-hint">
Select a user to assign this ticket to
</p>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 5: Visibility Settings -->
<div class="ascii-section-header">Visibility Settings</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="visibility">Ticket Visibility</label>
<select id="visibility" name="visibility" class="editable" data-action="toggle-visibility-groups">
<option value="public" selected>Public - All authenticated users</option>
<option value="internal">Internal - Specific groups only</option>
<option value="confidential">Confidential - Creator, assignee, admins only</option>
</select>
<p class="form-hint">
Controls who can view this ticket
</p>
</div>
<div id="visibilityGroupsContainer" class="detail-group is-hidden">
<label>Allowed Groups</label>
<div class="visibility-groups-list">
<?php
// Get all available groups
require_once __DIR__ . '/../models/UserModel.php';
$userModel = new UserModel($conn);
$allGroups = $userModel->getAllGroups();
foreach ($allGroups as $group):
?>
<label class="group-checkbox-label">
<input type="checkbox" name="visibility_groups[]" value="<?php echo htmlspecialchars($group); ?>" class="visibility-group-checkbox">
<span class="group-badge"><?php echo htmlspecialchars($group); ?></span>
</label>
<?php endforeach; ?>
<?php if (empty($allGroups)): ?>
<span class="text-muted">No groups available</span>
<?php endif; ?>
</div>
<p class="form-hint-warning">
Select which groups can view this ticket
</p>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 6: Detailed Description -->
<div class="ascii-section-header">Detailed Description</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group full-width">
<label for="description">Description *</label>
<textarea id="description" name="description" class="editable" rows="15" required placeholder="Provide a detailed description of the ticket..."></textarea>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 6: Form Actions -->
<div class="ascii-section-header">Form Actions</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="ticket-footer">
<button type="submit" class="btn primary">CREATE TICKET</button>
<button type="button" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">CANCEL</button>
</div>
</div>
</div>
</form>
</div> </div>
<!-- END OUTER FRAME --> </div>
<script nonce="<?php echo $nonce; ?>"> <!-- ── SECTION 3: Metadata ──────────────────────────────── -->
// Duplicate detection with debounce <div class="lt-frame lt-mb-md">
let duplicateCheckTimeout = null; <span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Metadata</div>
<div class="lt-section-body">
<div class="create-ticket-meta-grid">
document.getElementById('title').addEventListener('input', function() { <div class="lt-form-group">
clearTimeout(duplicateCheckTimeout); <label class="lt-label" for="status">Status</label>
const title = this.value.trim(); <select id="status" name="status" class="lt-select">
<option value="Open" selected>Open</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="priority">Priority</label>
<select id="priority" name="priority" class="lt-select">
<option value="1">P1 — Critical Impact</option>
<option value="2">P2 — High Impact</option>
<option value="3">P3 — Medium Impact</option>
<option value="4" selected>P4 — Low Impact</option>
<option value="5">P5 — Minimal Impact</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="category">Category</label>
<select id="category" name="category" class="lt-select">
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
<option value="General" selected>General</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="type">Type</label>
<select id="type" name="type" class="lt-select">
<option value="Maintenance">Maintenance</option>
<option value="Install">Install</option>
<option value="Task">Task</option>
<option value="Upgrade">Upgrade</option>
<option value="Issue" selected>Issue</option>
<option value="Problem">Problem</option>
</select>
</div>
</div><!-- /.create-ticket-meta-grid -->
</div>
</div>
<!-- ── SECTION 4: Assignment ────────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Assignment</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label" for="assigned_to">Assign To</label>
<select id="assigned_to" name="assigned_to" class="lt-select">
<option value="">— Unassigned —</option>
<?php if (!empty($allUsers)): ?>
<?php foreach ($allUsers as $u): ?>
<option value="<?= (int)$u['user_id'] ?>">
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
</option>
<?php endforeach ?>
<?php endif ?>
</select>
<p class="lt-form-hint">Leave blank to create as unassigned.</p>
</div>
</div>
</div>
<!-- ── SECTION 5: Visibility ────────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Visibility</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label" for="visibility">Who can see this ticket?</label>
<select id="visibility" name="visibility" class="lt-select" data-action="toggle-visibility-groups">
<option value="public" selected>Public — All authenticated users</option>
<option value="internal">Internal — Specific groups only</option>
<option value="confidential">Confidential — Creator, assignee, and admins only</option>
</select>
<p id="visibilityHint" class="lt-form-hint">Everyone who is logged in can view this ticket.</p>
</div>
<div id="visibilityGroupsContainer" class="lt-form-group is-hidden" aria-live="polite">
<label class="lt-label lt-text-cyan">Allowed Groups</label>
<div class="visibility-groups-list lt-flex lt-flex-wrap lt-flex-gap-sm">
<?php
require_once __DIR__ . '/../models/UserModel.php';
$userModel = new UserModel($conn);
$allGroups = $userModel->getAllGroups();
foreach ($allGroups as $group):
?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox visibility-group-checkbox"
name="visibility_groups[]"
value="<?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?>">
<span class="lt-badge"><?= htmlspecialchars($group) ?></span>
</label>
<?php endforeach ?>
<?php if (empty($allGroups)): ?>
<span class="lt-text-muted lt-text-sm">No groups available</span>
<?php endif ?>
</div>
<p class="lt-form-hint lt-form-hint--warn">Select at least one group for Internal visibility.</p>
</div>
</div>
</div>
<!-- ── SECTION 6: Description ───────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Description *</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-sr-only lt-label" for="description">Description</label>
<textarea id="description"
name="description"
class="lt-input lt-textarea"
rows="16"
required
aria-required="true"
placeholder="Provide a detailed description of the issue, steps to reproduce, expected vs. actual behavior, and any relevant context&hellip;"></textarea>
</div>
</div>
</div>
<!-- ── SECTION 7: Actions ───────────────────────────────── -->
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Actions</div>
<div class="lt-section-body">
<div class="lt-btn-group">
<button type="submit" class="lt-btn lt-btn-primary">CREATE TICKET</button>
<a href="/" class="lt-btn lt-btn-ghost">CANCEL</a>
</div>
</div>
</div>
</form>
<!-- Page-specific script: duplicate detection + visibility toggle -->
<script nonce="<?= $nonce ?>">
(function () {
'use strict';
// ── Duplicate detection ───────────────────────────────────
var _dupTimer = null;
document.getElementById('title').addEventListener('input', function () {
clearTimeout(_dupTimer);
var title = this.value.trim();
if (title.length < 5) { if (title.length < 5) {
document.getElementById('duplicateWarning').classList.add('is-hidden'); document.getElementById('duplicateWarning').classList.add('is-hidden');
return; return;
} }
_dupTimer = setTimeout(function () { checkDuplicates(title); }, 500);
// Debounce: wait 500ms after user stops typing
duplicateCheckTimeout = setTimeout(() => {
checkForDuplicates(title);
}, 500);
}); });
function checkForDuplicates(title) { function checkDuplicates(title) {
if (!window.lt || typeof lt.api === 'undefined') return;
lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title)) lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
.then(data => { .then(function (data) {
const warningDiv = document.getElementById('duplicateWarning'); var warn = document.getElementById('duplicateWarning');
const listDiv = document.getElementById('duplicatesList'); var list = document.getElementById('duplicatesList');
if (data.success && data.duplicates && data.duplicates.length > 0) { if (data.success && data.duplicates && data.duplicates.length > 0) {
let html = '<ul class="duplicate-list">'; var ul = document.createElement('ul');
data.duplicates.forEach(dup => { ul.className = 'duplicate-list lt-text-sm';
html += `<li> data.duplicates.forEach(function (dup) {
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank"> var li = document.createElement('li');
#${escapeHtml(dup.ticket_id)} li.className = 'lt-flex lt-flex-align-center lt-flex-gap-sm lt-mb-xs';
</a> var a = document.createElement('a');
- ${escapeHtml(dup.title)} a.href = '/ticket/' + encodeURIComponent(dup.ticket_id);
<span class="duplicate-meta">(${dup.similarity}% match, ${escapeHtml(dup.status)})</span> a.target = '_blank';
</li>`; a.textContent = '#' + dup.ticket_id;
var dash = document.createTextNode(' \u2014 ' + dup.title + ' ');
var badge = document.createElement('span');
badge.className = 'lt-text-muted';
badge.textContent = '(' + dup.similarity + '% match, ' + dup.status + ')';
var linkBtn = document.createElement('button');
linkBtn.type = 'button';
linkBtn.className = 'lt-btn lt-btn-ghost lt-btn-xs';
linkBtn.dataset.dupId = dup.ticket_id;
linkBtn.textContent = 'Link as duplicate';
linkBtn.title = 'After creating, this ticket will be linked as a duplicate of #' + dup.ticket_id;
linkBtn.addEventListener('click', function () {
var chosen = this.dataset.dupId;
document.getElementById('linkDuplicateOf').value = chosen;
// Update all buttons to show current selection
ul.querySelectorAll('[data-dup-id]').forEach(function (b) {
b.textContent = b.dataset.dupId === chosen ? '\u2713 Will link' : 'Link as duplicate';
b.classList.toggle('lt-btn-primary', b.dataset.dupId === chosen);
});
});
li.appendChild(a);
li.appendChild(dash);
li.appendChild(badge);
li.appendChild(linkBtn);
ul.appendChild(li);
}); });
html += '</ul>'; var hint = document.createElement('p');
html += '<p class="duplicate-hint">Consider checking these tickets before creating a new one.</p>'; hint.className = 'lt-text-xs lt-text-muted lt-mt-sm';
hint.textContent = 'Check these before creating. Use "Link as duplicate" to auto-link after create.';
listDiv.innerHTML = html; list.innerHTML = '';
warningDiv.classList.remove('is-hidden'); list.appendChild(ul);
list.appendChild(hint);
warn.classList.remove('is-hidden');
} else { } else {
warningDiv.classList.add('is-hidden'); warn.classList.add('is-hidden');
} }
}) })
.catch(error => { .catch(function () { /* silent — duplicate check is non-critical */ });
console.error('Error checking duplicates:', error);
});
} }
// ── Visibility groups toggle ──────────────────────────────
var visibilityHints = {
'public': 'Everyone who is logged in can view this ticket.',
'internal': 'Only members of the selected groups (plus admins) can view this ticket.',
'confidential': 'Only you, the assignee, and admins can view this ticket.'
};
function toggleVisibilityGroups() { function toggleVisibilityGroups() {
const visibility = document.getElementById('visibility').value; var vis = document.getElementById('visibility').value;
const groupsContainer = document.getElementById('visibilityGroupsContainer'); var container = document.getElementById('visibilityGroupsContainer');
if (visibility === 'internal') { var hint = document.getElementById('visibilityHint');
groupsContainer.classList.remove('is-hidden'); if (vis === 'internal') {
container.classList.remove('is-hidden');
} else { } else {
groupsContainer.classList.add('is-hidden'); container.classList.add('is-hidden');
document.querySelectorAll('.visibility-group-checkbox').forEach(cb => cb.checked = false); container.querySelectorAll('.visibility-group-checkbox').forEach(function (cb) { cb.checked = false; });
} }
if (hint) hint.textContent = visibilityHints[vis] || '';
} }
// Event delegation for data-action handlers // ── Template loader ───────────────────────────────────────
document.addEventListener('click', function(event) { function loadTemplate() {
const target = event.target.closest('[data-action]'); var tplId = document.getElementById('templateSelect').value;
if (!target) return; if (!tplId) return;
const action = target.dataset.action; // Warn before overwriting content the user has already typed
if (action === 'navigate') { var existingTitle = (document.getElementById('title').value || '').trim();
window.location.href = target.dataset.url; var existingDesc = (document.getElementById('description').value || '').trim();
if (existingTitle || existingDesc) {
if (!confirm('Applying this template will overwrite your current title and description. Continue?')) {
document.getElementById('templateSelect').value = '';
return;
}
} }
});
document.addEventListener('change', function(event) { lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
const target = event.target.closest('[data-action]'); .then(function (data) {
if (!data.success || !data.template) {
lt.toast.error('Failed to load template.');
return;
}
var t = data.template;
if (t.title) document.getElementById('title').value = t.title;
if (t.description) document.getElementById('description').value = t.description;
if (t.priority) document.getElementById('priority').value = t.priority;
if (t.category) document.getElementById('category').value = t.category;
if (t.type) document.getElementById('type').value = t.type;
// Trigger duplicate check after template fill
document.getElementById('title').dispatchEvent(new Event('input'));
lt.toast.success('Template applied.');
})
.catch(function () { lt.toast.error('Could not load template.'); });
}
// ── Event delegation ──────────────────────────────────────
document.addEventListener('change', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return; if (!target) return;
switch (target.getAttribute('data-action')) {
const action = target.dataset.action; case 'load-template': loadTemplate(); break;
if (action === 'load-template') { case 'toggle-visibility-groups': toggleVisibilityGroups(); break;
loadTemplate();
} else if (action === 'toggle-visibility-groups') {
toggleVisibilityGroups();
} }
}); });
if (window.lt) lt.keys.initDefaults(); if (window.lt) lt.keys.initDefaults();
</script> }());
</body> </script>
</html>
<?php include __DIR__ . '/layout_footer.php'; ?>
+874 -1018
View File
File diff suppressed because it is too large Load Diff
+1032 -809
View File
File diff suppressed because it is too large Load Diff
+177 -218
View File
@@ -1,238 +1,197 @@
<?php <?php
// Admin view for managing API keys
// Receives $apiKeys from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'API Keys';
$activeNav = 'admin-api-keys';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en"> <div class="lt-page-header">
<head> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<meta charset="UTF-8"> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <span class="lt-text-muted lt-text-xs">/</span>
<title>API Keys - Admin</title> <span class="lt-text-muted lt-text-xs">Admin: API Keys</span>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> </div>
<link rel="stylesheet" href="/assets/css/base.css"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320"> <!-- Generate new key -->
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <div class="lt-frame lt-mb-md">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script> <span class="lt-frame-bl">╚</span><span class="lt-frame-br"></span>
<script nonce="<?php echo $nonce; ?>"> <div class="lt-section-header">Generate New API Key</div>
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; <div class="lt-section-body">
</script> <form id="generateKeyForm" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-flex-align-end">
</head> <div class="lt-form-group" style="flex:2;margin:0">
<body> <label class="lt-label" for="keyName">Key Name *</label>
<div class="user-header"> <input type="text" id="keyName" required class="lt-input" placeholder="e.g., CI/CD Pipeline">
<div class="user-header-left"> </div>
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <div class="lt-form-group" style="flex:1;margin:0">
<span class="admin-page-title">Admin: API Keys</span> <label class="lt-label" for="expiresIn">Expires In</label>
</div> <select id="expiresIn" class="lt-select">
<div class="user-header-right"> <option value="">Never</option>
<?php if (isset($GLOBALS['currentUser'])): ?> <option value="30">30 days</option>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span> <option value="90">90 days</option>
<span class="admin-badge">[ ADMIN ]</span> <option value="180">180 days</option>
<?php endif; ?> <option value="365">1 year</option>
</div> </select>
</div>
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
</form>
<!-- New key display (hidden by default) -->
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
<div class="lt-subsection-header lt-text-amber">&#x26A0; Copy this key now — you won't see it again!</div>
<div class="lt-flex lt-flex-gap-sm lt-mt-sm">
<input type="text" id="newKeyValue" readonly class="lt-input" style="flex:1;font-family:monospace;opacity:1;cursor:text">
<button type="button" class="lt-btn lt-btn-sm" data-action="copy-api-key">COPY</button>
</div>
</div> </div>
</div>
</div>
<div class="ascii-frame-outer admin-container"> <!-- Existing keys -->
<span class="bottom-left-corner">╚</span> <div class="lt-frame lt-mb-md">
<span class="bottom-right-corner">╝</span> <span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Existing API Keys</div>
<div class="ascii-section-header">API Key Management</div> <div class="lt-section-body">
<div class="ascii-content"> <div class="lt-table-wrap">
<!-- Generate New Key Form --> <table class="lt-table lt-table-responsive" aria-label="API keys">
<div class="ascii-frame-inner"> <thead>
<h3 class="admin-section-title">Generate New API Key</h3> <tr>
<form id="generateKeyForm" class="admin-form-row"> <th scope="col">Name</th>
<div class="admin-form-field"> <th scope="col">Key Prefix</th>
<label class="admin-label" for="keyName">Key Name *</label> <th scope="col">Created By</th>
<input type="text" id="keyName" required placeholder="e.g., CI/CD Pipeline" class="admin-input"> <th scope="col">Created</th>
</div> <th scope="col">Expires</th>
<div class="admin-form-field"> <th scope="col">Last Used</th>
<label class="admin-label" for="expiresIn">Expires In</label> <th scope="col">Status</th>
<select id="expiresIn" class="admin-input"> <th scope="col">Actions</th>
<option value="">Never</option> </tr>
<option value="30">30 days</option> </thead>
<option value="90">90 days</option> <tbody>
<option value="180">180 days</option> <?php if (empty($apiKeys)): ?>
<option value="365">1 year</option> <tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr>
</select> <?php else: foreach ($apiKeys as $key): ?>
</div> <?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
<div> <tr id="key-row-<?= (int)$key['api_key_id'] ?>">
<button type="submit" class="btn">GENERATE KEY</button> <td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
</div> <td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>&hellip;</code></td>
</form> <td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td>
</div> <td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td>
<td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
<!-- New Key Display (hidden by default) --> <?= $key['expires_at'] ? date('Y-m-d', strtotime($key['expires_at'])) . ($expired ? ' (Expired)' : '') : 'Never' ?>
<div id="newKeyDisplay" class="ascii-frame-inner key-generated-alert is-hidden"> </td>
<h3 class="admin-section-title">New API Key Generated</h3> <td data-label="Last Used" class="lt-text-xs lt-text-muted">
<p class="text-danger text-sm mb-1"> <?= $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never' ?>
Copy this key now. You won't be able to see it again! </td>
</p> <td data-label="Status">
<div class="admin-form-row"> <?php if ($key['is_active']): ?>
<input type="text" id="newKeyValue" readonly class="admin-input"> <span class="lt-status lt-status-open">Active</span>
<button data-action="copy-api-key" class="btn" title="Copy to clipboard">COPY</button> <?php else: ?>
</div> <span class="lt-status lt-status-closed">Revoked</span>
</div> <?php endif ?>
</td>
<!-- Existing Keys Table --> <td data-label="Actions">
<div class="ascii-frame-inner"> <?php if ($key['is_active']): ?>
<h3 class="admin-section-title">Existing API Keys</h3> <button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
<div class="table-wrapper"> data-action="revoke-key" data-id="<?= (int)$key['api_key_id'] ?>">REVOKE</button>
<table> <?php else: ?>
<thead> <span class="lt-text-muted lt-text-xs">—</span>
<tr> <?php endif ?>
<th>Name</th> </td>
<th>Key Prefix</th> </tr>
<th>Created By</th> <?php endforeach; endif ?>
<th>Created At</th> </tbody>
<th>Expires At</th> </table>
<th>Last Used</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($apiKeys)): ?>
<tr>
<td colspan="8" class="empty-state">No API keys found. Generate one above.</td>
</tr>
<?php else: ?>
<?php foreach ($apiKeys as $key): ?>
<tr id="key-row-<?php echo $key['api_key_id']; ?>">
<td><?php echo htmlspecialchars($key['key_name']); ?></td>
<td class="mono">
<code><?php echo htmlspecialchars($key['key_prefix']); ?>...</code>
</td>
<td><?php echo htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown'); ?></td>
<td class="nowrap"><?php echo date('Y-m-d H:i', strtotime($key['created_at'])); ?></td>
<td class="nowrap">
<?php if ($key['expires_at']): ?>
<?php $expired = strtotime($key['expires_at']) < time(); ?>
<span class="<?php echo $expired ? 'text-danger' : ''; ?>">
<?php echo date('Y-m-d', strtotime($key['expires_at'])); ?>
<?php if ($expired): ?> (Expired)<?php endif; ?>
</span>
<?php else: ?>
<span class="text-cyan">Never</span>
<?php endif; ?>
</td>
<td class="nowrap">
<?php echo $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never'; ?>
</td>
<td>
<?php if ($key['is_active']): ?>
<span class="text-open">Active</span>
<?php else: ?>
<span class="text-closed">Revoked</span>
<?php endif; ?>
</td>
<td>
<?php if ($key['is_active']): ?>
<button data-action="revoke-key" data-id="<?php echo $key['api_key_id']; ?>" class="btn btn-secondary btn-small">
REVOKE
</button>
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- API Usage Info -->
<div class="ascii-frame-inner">
<h3 class="admin-section-title">API Usage</h3>
<p>Include the API key in your requests using the Authorization header:</p>
<pre class="admin-code-block"><code>Authorization: Bearer YOUR_API_KEY</code></pre>
<p class="text-muted text-sm">
API keys provide programmatic access to create and manage tickets. Keep your keys secure and rotate them regularly.
</p>
</div>
</div>
</div> </div>
</div>
</div>
<script nonce="<?php echo $nonce; ?>"> <!-- API usage -->
// Event delegation for data-action handlers <div class="lt-frame">
document.addEventListener('click', function(event) { <span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
const target = event.target.closest('[data-action]'); <div class="lt-section-header">API Usage</div>
if (!target) return; <div class="lt-section-body">
<p class="lt-text-sm lt-text-muted">Include the API key in your requests using the Authorization header:</p>
<div class="lt-code-block">
<div class="lt-code-header">
<span class="lt-code-lang">HTTP HEADER</span>
<button type="button" class="lt-code-copy lt-btn-sm"
data-copy="Authorization: Bearer YOUR_API_KEY"
data-copy-toast>COPY</button>
</div>
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
Example — create a ticket via cURL:<br>
</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl -X POST https://your-instance/api/create_ticket.php \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">API keys provide programmatic access to create and manage tickets. Keep keys secure and rotate them regularly.</p>
</div>
</div>
const action = target.dataset.action; <script nonce="<?= $nonce ?>">
switch (action) { document.addEventListener('click', function (e) {
case 'copy-api-key': var target = e.target.closest('[data-action]');
copyApiKey(); if (!target) return;
break; switch (target.getAttribute('data-action')) {
case 'revoke-key': case 'copy-api-key': copyApiKey(); break;
revokeKey(target.dataset.id); case 'revoke-key': revokeKey(target.getAttribute('data-id')); break;
break; case 'copy-header-example':
} navigator.clipboard.writeText('Authorization: Bearer YOUR_API_KEY')
}); .then(function() { lt.toast.success('Copied!'); })
.catch(function() { lt.toast.error('Copy failed'); });
document.getElementById('generateKeyForm').addEventListener('submit', async function(e) { break;
e.preventDefault(); }
});
const keyName = document.getElementById('keyName').value.trim();
const expiresIn = document.getElementById('expiresIn').value;
if (!keyName) {
lt.toast.error('Please enter a key name');
return;
}
try {
const data = await lt.api.post('/api/generate_api_key.php', {
key_name: keyName,
expires_in_days: expiresIn || null
});
document.getElementById('generateKeyForm').addEventListener('submit', function (e) {
e.preventDefault();
var keyName = document.getElementById('keyName').value.trim();
var expiresIn = document.getElementById('expiresIn').value;
if (!keyName) { lt.toast.error('Please enter a key name'); return; }
lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null })
.then(function (data) {
if (data.success) { if (data.success) {
// Show the new key
document.getElementById('newKeyValue').value = data.api_key; document.getElementById('newKeyValue').value = data.api_key;
document.getElementById('newKeyDisplay').classList.remove('is-hidden'); document.getElementById('newKeyDisplay').classList.remove('is-hidden');
document.getElementById('keyName').value = ''; document.getElementById('keyName').value = '';
lt.toast.success('API key generated!');
lt.toast.success('API key generated successfully'); setTimeout(function () { location.reload(); }, 5000);
// Reload page after 5 seconds to show new key in table
setTimeout(() => location.reload(), 5000);
} else { } else {
lt.toast.error(data.error || 'Failed to generate API key'); lt.toast.error(data.error || 'Failed to generate API key');
} }
} catch (error) { }).catch(function (err) { lt.toast.error('Error: ' + err.message); });
lt.toast.error('Error generating API key: ' + error.message); });
}
function copyApiKey() {
var val = document.getElementById('newKeyValue').value;
lt.copy(val).then(function () {
lt.toast.success('Copied to clipboard!');
}).catch(function () {
lt.toast.error('Copy failed — select the key manually');
}); });
}
function copyApiKey() { function revokeKey(keyId) {
const keyInput = document.getElementById('newKeyValue'); showConfirmModal('Revoke API Key', 'Revoke this API key? This cannot be undone.', 'error', function () {
keyInput.select(); lt.api.post('/api/revoke_api_key.php', { key_id: keyId })
document.execCommand('copy'); .then(function (data) {
lt.toast.success('API key copied to clipboard'); if (data.success) { lt.toast.success('API key revoked'); location.reload(); }
} else lt.toast.error(data.error || 'Failed to revoke');
}).catch(function (err) { lt.toast.error('Error: ' + err.message); });
});
}
function revokeKey(keyId) { if (window.lt) lt.keys.initDefaults();
showConfirmModal('Revoke API Key', 'Are you sure you want to revoke this API key? This action cannot be undone.', 'error', function() { </script>
lt.api.post('/api/revoke_api_key.php', { key_id: keyId })
.then(data => { <?php include __DIR__ . '/../../views/layout_footer.php'; ?>
if (data.success) {
lt.toast.success('API key revoked successfully');
location.reload();
} else {
lt.toast.error(data.error || 'Failed to revoke API key');
}
})
.catch(error => {
lt.toast.error('Error revoking API key: ' + error.message);
});
});
}
</script>
</body>
</html>
+144 -159
View File
@@ -1,166 +1,151 @@
<?php <?php
// Admin view for browsing audit logs
// Receives $auditLogs, $totalPages, $page, $filters from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Audit Log';
$activeNav = 'admin-audit-log';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en"> <div class="lt-page-header">
<head> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<meta charset="UTF-8"> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <span class="lt-text-muted lt-text-xs">/</span>
<title>Audit Log - Admin</title> <span class="lt-text-muted lt-text-xs">Admin: Audit Log</span>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> </div>
<link rel="stylesheet" href="/assets/css/base.css"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320"> <div class="lt-frame">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <span class="lt-frame-bl">╚</span><span class="lt-frame-br"></span>
<script nonce="<?php echo $nonce; ?>"> <div class="lt-section-header">Audit Log Browser</div>
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; <div class="lt-section-body">
</script>
</head> <!-- Filters -->
<body> <form method="GET" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-mb-md" role="search" aria-label="Filter audit logs">
<div class="user-header"> <div class="lt-form-group" style="margin:0">
<div class="user-header-left"> <label class="lt-label" for="action_type">Action Type</label>
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <select name="action_type" id="action_type" class="lt-select lt-select-sm">
<span class="admin-page-title">Admin: Audit Log</span> <option value="">All Actions</option>
</div> <?php foreach (['create','update','delete','comment','assign','status_change','login','security'] as $a): ?>
<div class="user-header-right"> <option value="<?= htmlspecialchars($a, ENT_QUOTES, 'UTF-8') ?>" <?= ($filters['action_type'] ?? '') === $a ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $a)), ENT_QUOTES, 'UTF-8') ?></option>
<?php if (isset($GLOBALS['currentUser'])): ?> <?php endforeach ?>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span> </select>
<span class="admin-badge">[ ADMIN ]</span> </div>
<?php endif; ?> <div class="lt-form-group" style="margin:0">
</div> <label class="lt-label" for="user_id">User</label>
<select name="user_id" id="user_id" class="lt-select lt-select-sm">
<option value="">All Users</option>
<?php if (isset($users)): foreach ($users as $u): ?>
<option value="<?= (int)$u['user_id'] ?>" <?= ($filters['user_id'] ?? '') == $u['user_id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
</option>
<?php endforeach; endif ?>
</select>
</div>
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="date_from">Date From</label>
<input type="date" name="date_from" id="date_from" class="lt-input lt-input-sm"
value="<?= htmlspecialchars($filters['date_from'] ?? '') ?>">
</div>
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="date_to">Date To</label>
<input type="date" name="date_to" id="date_to" class="lt-input lt-input-sm"
value="<?= htmlspecialchars($filters['date_to'] ?? '') ?>">
</div>
<div class="lt-form-group lt-flex lt-flex-align-center lt-flex-gap-sm" style="margin:0;align-self:flex-end">
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">FILTER</button>
<a href="?" class="lt-btn lt-btn-ghost lt-btn-sm">RESET</a>
</div>
</form>
<!-- Log table -->
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="Audit log entries">
<thead>
<tr>
<th scope="col">Timestamp</th>
<th scope="col">User</th>
<th scope="col">Action</th>
<th scope="col">Entity</th>
<th scope="col">Entity ID</th>
<th scope="col">Details</th>
<th scope="col">IP Address</th>
</tr>
</thead>
<tbody>
<?php if (empty($auditLogs)): ?>
<tr><td colspan="7" class="lt-empty">No audit log entries found.</td></tr>
<?php else: foreach ($auditLogs as $log): ?>
<tr>
<td data-label="Timestamp" class="lt-text-xs"><?= date('Y-m-d H:i:s', strtotime($log['created_at'])) ?></td>
<td data-label="User"><?= htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System') ?></td>
<td data-label="Action"><span class="lt-text-amber"><?= htmlspecialchars($log['action_type']) ?></span></td>
<td data-label="Entity" class="lt-text-xs"><?= htmlspecialchars($log['entity_type'] ?? '-') ?></td>
<td data-label="Entity ID" class="lt-text-xs">
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?>
<a href="/ticket/<?= htmlspecialchars($log['entity_id']) ?>"><?= htmlspecialchars($log['entity_id']) ?></a>
<?php else: ?>
<?= htmlspecialchars($log['entity_id'] ?? '-') ?>
<?php endif ?>
</td>
<td data-label="Details" class="lt-text-xs lt-text-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis">
<?php
if ($log['details']) {
$det = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
echo '<code>' . htmlspecialchars(is_array($det) ? json_encode($det) : (string)$log['details']) . '</code>';
} else {
echo '-';
}
?>
</td>
<td data-label="IP" class="lt-text-xs lt-text-muted"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
</tr>
<?php endforeach; endif ?>
</tbody>
</table>
</div> </div>
<div class="ascii-frame-outer admin-container-wide"> <!-- Pagination -->
<span class="bottom-left-corner">╚</span> <?php if (($totalPages ?? 1) > 1): ?>
<span class="bottom-right-corner">╝</span> <div class="lt-pagination" role="navigation">
<?php
<div class="ascii-section-header">Audit Log Browser</div> $params = $_GET;
<div class="ascii-content"> $start = max(1, $page - 2);
<div class="ascii-frame-inner"> $end = min($totalPages, $page + 2);
<!-- Filters --> if ($page > 1) {
<form method="GET" class="admin-form-row"> $params['page'] = $page - 1;
<div class="admin-form-field"> $pUrl = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
<label class="admin-label" for="action_type">Action Type</label> echo '<a href="' . $pUrl . '" class="lt-btn lt-btn-sm" aria-label="Previous page">&#xAB;</a> ';
<select name="action_type" id="action_type" class="admin-input"> }
<option value="">All Actions</option> if ($start > 1) {
<option value="create" <?php echo ($filters['action_type'] ?? '') === 'create' ? 'selected' : ''; ?>>Create</option> $params['page'] = 1;
<option value="update" <?php echo ($filters['action_type'] ?? '') === 'update' ? 'selected' : ''; ?>>Update</option> echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">1</a> ';
<option value="delete" <?php echo ($filters['action_type'] ?? '') === 'delete' ? 'selected' : ''; ?>>Delete</option> if ($start > 2) echo '<span class="lt-text-muted lt-text-xs">&hellip;</span>';
<option value="comment" <?php echo ($filters['action_type'] ?? '') === 'comment' ? 'selected' : ''; ?>>Comment</option> }
<option value="assign" <?php echo ($filters['action_type'] ?? '') === 'assign' ? 'selected' : ''; ?>>Assign</option> for ($i = $start; $i <= $end; $i++) {
<option value="status_change" <?php echo ($filters['action_type'] ?? '') === 'status_change' ? 'selected' : ''; ?>>Status Change</option> $params['page'] = $i;
<option value="login" <?php echo ($filters['action_type'] ?? '') === 'login' ? 'selected' : ''; ?>>Login</option> $url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
<option value="security" <?php echo ($filters['action_type'] ?? '') === 'security' ? 'selected' : ''; ?>>Security</option> $class = ($i == $page) ? ' lt-btn-primary' : '';
</select> $curr = ($i == $page) ? ' aria-current="page"' : '';
</div> echo '<a href="' . $url . '" class="lt-btn lt-btn-sm' . $class . '"' . $curr . '>' . $i . '</a> ';
<div class="admin-form-field"> }
<label class="admin-label" for="user_id">User</label> if ($end < $totalPages) {
<select name="user_id" id="user_id" class="admin-input"> if ($end < $totalPages - 1) echo '<span class="lt-text-muted lt-text-xs">&hellip;</span>';
<option value="">All Users</option> $params['page'] = $totalPages;
<?php if (isset($users)): foreach ($users as $user): ?> echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">' . $totalPages . '</a> ';
<option value="<?php echo $user['user_id']; ?>" <?php echo ($filters['user_id'] ?? '') == $user['user_id'] ? 'selected' : ''; ?>> }
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?> if ($page < $totalPages) {
</option> $params['page'] = $page + 1;
<?php endforeach; endif; ?> $nUrl = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
</select> echo '<a href="' . $nUrl . '" class="lt-btn lt-btn-sm" aria-label="Next page">&#xBB;</a>';
</div> }
<div class="admin-form-field"> ?>
<label class="admin-label" for="date_from">Date From</label>
<input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="admin-input">
</div>
<div class="admin-form-field">
<label class="admin-label" for="date_to">Date To</label>
<input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="admin-input">
</div>
<div class="admin-form-actions">
<button type="submit" class="btn">FILTER</button>
<a href="?" class="btn btn-secondary">RESET</a>
</div>
</form>
<!-- Log Table -->
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>User</th>
<th>Action</th>
<th>Entity</th>
<th>Entity ID</th>
<th>Details</th>
<th>IP Address</th>
</tr>
</thead>
<tbody>
<?php if (empty($auditLogs)): ?>
<tr>
<td colspan="7" class="empty-state">No audit log entries found.</td>
</tr>
<?php else: ?>
<?php foreach ($auditLogs as $log): ?>
<tr>
<td class="nowrap"><?php echo date('Y-m-d H:i:s', strtotime($log['created_at'])); ?></td>
<td><?php echo htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System'); ?></td>
<td>
<span class="text-amber"><?php echo htmlspecialchars($log['action_type']); ?></span>
</td>
<td><?php echo htmlspecialchars($log['entity_type'] ?? '-'); ?></td>
<td>
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?>
<a href="/ticket/<?php echo htmlspecialchars($log['entity_id']); ?>" class="text-green">
<?php echo htmlspecialchars($log['entity_id']); ?>
</a>
<?php else: ?>
<?php echo htmlspecialchars($log['entity_id'] ?? '-'); ?>
<?php endif; ?>
</td>
<td class="td-truncate">
<?php
if ($log['details']) {
$details = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
if (is_array($details)) {
echo '<code>' . htmlspecialchars(json_encode($details)) . '</code>';
} else {
echo htmlspecialchars($log['details']);
}
} else {
echo '-';
}
?>
</td>
<td class="nowrap text-sm"><?php echo htmlspecialchars($log['ip_address'] ?? '-'); ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Pagination -->
<?php if ($totalPages > 1): ?>
<div class="pagination">
<?php
$params = $_GET;
for ($i = 1; $i <= min($totalPages, 10); $i++) {
$params['page'] = $i;
$activeClass = ($i == $page) ? 'active' : '';
$url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
echo "<a href='$url' class='btn btn-small $activeClass'>$i</a> ";
}
if ($totalPages > 10) {
echo "...";
}
?>
</div>
<?php endif; ?>
</div>
</div>
</div> </div>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script> <?php endif ?>
</body>
</html> </div>
</div>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+228 -260
View File
@@ -1,278 +1,246 @@
<?php <?php
// Admin view for managing custom fields
// Receives $customFields from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Custom Fields';
$activeNav = 'admin-custom-fields';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en"> <div class="lt-page-header">
<head> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<meta charset="UTF-8"> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <span class="lt-text-muted lt-text-xs">/</span>
<title>Custom Fields - Admin</title> <span class="lt-text-muted lt-text-xs">Admin: Custom Fields</span>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> </div>
<link rel="stylesheet" href="/assets/css/base.css"> <button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW FIELD</button>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <div class="lt-frame">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script> <span class="lt-frame-bl">╚</span><span class="lt-frame-br"></span>
<script nonce="<?php echo $nonce; ?>"> <div class="lt-section-header">Custom Field Definitions</div>
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; <div class="lt-section-body">
</script> <p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
</head> Custom fields extend tickets with additional metadata. Fields appear on the ticket form based on category.
<body> </p>
<div class="user-header"> <div class="lt-table-wrap">
<div class="user-header-left"> <table class="lt-table lt-table-responsive" aria-label="Custom fields">
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <thead>
<span class="admin-page-title">Admin: Custom Fields</span> <tr>
</div> <th scope="col">Order</th>
<div class="user-header-right"> <th scope="col">Field Name</th>
<?php if (isset($GLOBALS['currentUser'])): ?> <th scope="col">Label</th>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span> <th scope="col">Type</th>
<span class="admin-badge">[ ADMIN ]</span> <th scope="col">Category</th>
<?php endif; ?> <th scope="col">Required</th>
</div> <th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($customFields)): ?>
<tr><td colspan="8" class="lt-empty">No custom fields defined. Create fields to extend ticket metadata.</td></tr>
<?php else: foreach ($customFields as $field): ?>
<tr>
<td data-label="Order" class="lt-text-xs lt-text-muted"><?= (int)$field['display_order'] ?></td>
<td data-label="Field Name"><code class="lt-text-cyan lt-text-xs"><?= htmlspecialchars($field['field_name']) ?></code></td>
<td data-label="Label"><strong><?= htmlspecialchars($field['field_label']) ?></strong></td>
<td data-label="Type" class="lt-text-xs"><?= ucfirst($field['field_type']) ?></td>
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($field['category'] ?? 'All') ?></td>
<td data-label="Required" style="text-align:center">
<?= $field['is_required'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td>
<td data-label="Status">
<span class="lt-status <?= $field['is_active'] ? 'lt-status-open' : 'lt-status-closed' ?>">
<?= $field['is_active'] ? 'Active' : 'Inactive' ?>
</span>
</td>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-field" data-id="<?= $field['field_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-field" data-id="<?= $field['field_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; endif ?>
</tbody>
</table>
</div> </div>
</div>
</div>
<div class="ascii-frame-outer admin-container"> <!-- Create/Edit Modal -->
<span class="bottom-left-corner">╚</span> <div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog"
<span class="bottom-right-corner">╝</span> aria-modal="true" aria-labelledby="cfModalTitle">
<div class="lt-modal">
<div class="ascii-section-header">Custom Fields Management</div> <div class="lt-modal-header">
<div class="ascii-content"> <span class="lt-modal-title" id="cfModalTitle">Create Custom Field</span>
<div class="ascii-frame-inner"> <button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
<div class="admin-header-row">
<h2>Custom Field Definitions</h2>
<button data-action="show-create-modal" class="btn">+ NEW FIELD</button>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Order</th>
<th>Field Name</th>
<th>Label</th>
<th>Type</th>
<th>Category</th>
<th>Required</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($customFields)): ?>
<tr>
<td colspan="8" class="empty-state">No custom fields defined.</td>
</tr>
<?php else: ?>
<?php foreach ($customFields as $field): ?>
<tr>
<td><?php echo $field['display_order']; ?></td>
<td><code><?php echo htmlspecialchars($field['field_name']); ?></code></td>
<td><?php echo htmlspecialchars($field['field_label']); ?></td>
<td><?php echo ucfirst($field['field_type']); ?></td>
<td><?php echo htmlspecialchars($field['category'] ?? 'All'); ?></td>
<td><?php echo $field['is_required'] ? 'Yes' : 'No'; ?></td>
<td>
<span class="<?php echo $field['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $field['is_active'] ? 'Active' : 'Inactive'; ?>
</span>
</td>
<td>
<button data-action="edit-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="delete-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
<form id="fieldForm">
<!-- Create/Edit Modal --> <input type="hidden" id="field_id" name="field_id">
<div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle"> <div class="lt-modal-body">
<div class="lt-modal lt-modal-sm"> <div class="lt-form-group">
<div class="lt-modal-header"> <label class="lt-label" for="field_name">Field Name * <span class="lt-text-muted lt-text-xs">(internal, lowercase_underscore)</span></label>
<span class="lt-modal-title" id="modalTitle">Create Custom Field</span> <input type="text" id="field_name" name="field_name" class="lt-input" required
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button> pattern="[a-z_]+" placeholder="e.g., server_name">
</div>
<form id="fieldForm">
<input type="hidden" id="field_id" name="field_id">
<div class="lt-modal-body">
<div class="setting-row">
<label for="field_name">Field Name * (internal)</label>
<input type="text" id="field_name" name="field_name" required pattern="[a-z_]+" placeholder="e.g., server_name">
</div>
<div class="setting-row">
<label for="field_label">Field Label * (display)</label>
<input type="text" id="field_label" name="field_label" required placeholder="e.g., Server Name">
</div>
<div class="setting-row">
<label for="field_type">Field Type *</label>
<select id="field_type" name="field_type" required data-action="toggle-options-field">
<option value="text">Text</option>
<option value="textarea">Text Area</option>
<option value="select">Dropdown (Select)</option>
<option value="checkbox">Checkbox</option>
<option value="date">Date</option>
<option value="number">Number</option>
</select>
</div>
<div class="setting-row is-hidden" id="options_row">
<label for="field_options">Options (one per line)</label>
<textarea id="field_options" name="field_options" rows="4" placeholder="Option 1&#10;Option 2&#10;Option 3"></textarea>
</div>
<div class="setting-row">
<label for="category">Category (empty = all)</label>
<select id="category" name="category">
<option value="">All Categories</option>
<option value="General">General</option>
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
</select>
</div>
<div class="setting-row">
<label for="display_order">Display Order</label>
<input type="number" id="display_order" name="display_order" value="0" min="0">
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_required" name="is_required"> Required field</label>
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div> </div>
</div> <div class="lt-form-group">
<label class="lt-label" for="field_label">Field Label * <span class="lt-text-muted lt-text-xs">(display name)</span></label>
<input type="text" id="field_label" name="field_label" class="lt-input" required
placeholder="e.g., Server Name">
</div>
<div class="lt-form-group">
<label class="lt-label" for="field_type">Field Type *</label>
<select id="field_type" name="field_type" class="lt-select" required
data-action="toggle-options-field">
<option value="text">Text</option>
<option value="textarea">Text Area</option>
<option value="select">Dropdown (Select)</option>
<option value="checkbox">Checkbox</option>
<option value="date">Date</option>
<option value="number">Number</option>
</select>
</div>
<div class="lt-form-group is-hidden" id="options_row">
<label class="lt-label" for="field_options">Options <span class="lt-text-muted lt-text-xs">(one per line)</span></label>
<textarea id="field_options" name="field_options" class="lt-input lt-textarea"
rows="4" placeholder="Option 1&#10;Option 2&#10;Option 3"></textarea>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cf-category">Category <span class="lt-text-muted lt-text-xs">(empty = all categories)</span></label>
<select id="cf-category" name="category" class="lt-select">
<option value="">All Categories</option>
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
<option value="<?= $c ?>"><?= $c ?></option>
<?php endforeach ?>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="display_order">Display Order</label>
<input type="number" id="display_order" name="display_order" class="lt-input"
value="0" min="0" style="max-width:8rem">
</div>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="is_required" name="is_required">
Required field
</label>
</div>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="cf_is_active" name="is_active" checked>
Active
</label>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?= $nonce ?>">
function showCreateModal() { document.addEventListener('click', function (e) {
document.getElementById('modalTitle').textContent = 'Create Custom Field'; var target = e.target.closest('[data-action]');
document.getElementById('fieldForm').reset(); if (!target) return;
document.getElementById('field_id').value = ''; switch (target.getAttribute('data-action')) {
document.getElementById('is_active').checked = true; case 'show-create-modal': showCreateModal(); break;
toggleOptionsField(); case 'edit-field': editField(target.getAttribute('data-id')); break;
lt.modal.open('fieldModal'); case 'delete-field': deleteField(target.getAttribute('data-id')); break;
} }
});
function closeModal() { document.addEventListener('change', function (e) {
lt.modal.close('fieldModal'); var target = e.target.closest('[data-action]');
} if (!target) return;
if (target.getAttribute('data-action') === 'toggle-options-field') toggleOptionsField();
});
// Event delegation for data-action handlers document.getElementById('fieldForm').addEventListener('submit', function (e) {
document.addEventListener('click', function(event) { saveField(e);
const target = event.target.closest('[data-action]'); });
if (!target) return;
const action = target.dataset.action; if (window.lt) lt.keys.initDefaults();
switch (action) {
case 'show-create-modal':
showCreateModal();
break;
case 'edit-field':
editField(target.dataset.id);
break;
case 'delete-field':
deleteField(target.dataset.id);
break;
}
});
document.addEventListener('change', function(event) { function toggleOptionsField() {
const target = event.target.closest('[data-action]'); var type = document.getElementById('field_type').value;
if (!target) return; document.getElementById('options_row').classList.toggle('is-hidden', type !== 'select');
}
if (target.dataset.action === 'toggle-options-field') { function showCreateModal() {
document.getElementById('cfModalTitle').textContent = 'Create Custom Field';
document.getElementById('fieldForm').reset();
document.getElementById('field_id').value = '';
document.getElementById('cf_is_active').checked = true;
toggleOptionsField();
lt.modal.open('fieldModal');
}
function editField(id) {
lt.api.get('/api/custom_fields.php?id=' + id)
.then(function (data) {
if (data.success && data.field) {
var f = data.field;
document.getElementById('field_id').value = f.field_id;
document.getElementById('field_name').value = f.field_name;
document.getElementById('field_label').value = f.field_label;
document.getElementById('field_type').value = f.field_type;
document.getElementById('cf-category').value = f.category || '';
document.getElementById('display_order').value = f.display_order;
document.getElementById('is_required').checked = f.is_required == 1;
document.getElementById('cf_is_active').checked = f.is_active == 1;
toggleOptionsField(); toggleOptionsField();
} if (f.field_options && f.field_options.options) {
}); document.getElementById('field_options').value = f.field_options.options.join('\n');
// Form submit handler
document.getElementById('fieldForm').addEventListener('submit', function(e) {
saveField(e);
});
if (window.lt) lt.keys.initDefaults();
function toggleOptionsField() {
const type = document.getElementById('field_type').value;
document.getElementById('options_row').classList.toggle('is-hidden', type !== 'select');
}
function saveField(e) {
e.preventDefault();
const form = document.getElementById('fieldForm');
const data = {
field_id: document.getElementById('field_id').value,
field_name: document.getElementById('field_name').value,
field_label: document.getElementById('field_label').value,
field_type: document.getElementById('field_type').value,
category: document.getElementById('category').value || null,
display_order: parseInt(document.getElementById('display_order').value) || 0,
is_required: document.getElementById('is_required').checked ? 1 : 0,
is_active: document.getElementById('is_active').checked ? 1 : 0
};
if (data.field_type === 'select') {
const options = document.getElementById('field_options').value.split('\n').filter(o => o.trim());
data.field_options = { options: options };
}
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);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
lt.toast.error(result.error || 'Failed to save');
} }
}).catch(err => lt.toast.error('Failed to save')); document.getElementById('cfModalTitle').textContent = 'Edit Custom Field';
} lt.modal.open('fieldModal');
} else {
lt.toast.error(data.error || 'Failed to load field');
}
}).catch(function () { lt.toast.error('Failed to load field'); });
}
function editField(id) { function deleteField(id) {
lt.api.get('/api/custom_fields.php?id=' + id) showConfirmModal('Delete Custom Field', 'Delete this custom field? All values will be lost.', 'error', function () {
.then(data => { lt.api.delete('/api/custom_fields.php?id=' + id)
if (data.success && data.field) { .then(function (data) {
const f = data.field; if (data.success) window.location.reload();
document.getElementById('field_id').value = f.field_id; else lt.toast.error(data.error || 'Failed to delete');
document.getElementById('field_name').value = f.field_name; }).catch(function () { lt.toast.error('Failed to delete'); });
document.getElementById('field_label').value = f.field_label; });
document.getElementById('field_type').value = f.field_type; }
document.getElementById('category').value = f.category || '';
document.getElementById('display_order').value = f.display_order;
document.getElementById('is_required').checked = f.is_required == 1;
document.getElementById('is_active').checked = f.is_active == 1;
toggleOptionsField();
if (f.field_options && f.field_options.options) {
document.getElementById('field_options').value = f.field_options.options.join('\n');
}
document.getElementById('modalTitle').textContent = 'Edit Custom Field';
lt.modal.open('fieldModal');
}
});
}
function deleteField(id) { function saveField(e) {
showConfirmModal('Delete Custom Field', 'Delete this custom field? All values will be lost.', 'error', function() { e.preventDefault();
lt.api.delete('/api/custom_fields.php?id=' + id) var data = {
.then(data => { field_id: document.getElementById('field_id').value,
if (data.success) window.location.reload(); field_name: document.getElementById('field_name').value,
else lt.toast.error(data.error || 'Failed to delete'); field_label: document.getElementById('field_label').value,
}).catch(err => lt.toast.error('Failed to delete')); field_type: document.getElementById('field_type').value,
}); category: document.getElementById('cf-category').value || null,
} display_order: parseInt(document.getElementById('display_order').value) || 0,
</script> is_required: document.getElementById('is_required').checked ? 1 : 0,
</body> is_active: document.getElementById('cf_is_active').checked ? 1 : 0,
</html> };
if (data.field_type === 'select') {
var opts = document.getElementById('field_options').value.split('\n').filter(function (o) { return o.trim(); });
data.field_options = { options: opts };
}
var url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : '');
var apiCall = data.field_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(function (result) {
if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
</script>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+285 -326
View File
@@ -1,346 +1,305 @@
<?php <?php
// Admin view for managing recurring tickets
// Receives $recurringTickets from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Recurring Tickets';
$activeNav = 'admin-recurring';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recurring Tickets - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: Recurring Tickets</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer admin-container"> <div class="lt-page-header">
<span class="bottom-left-corner">╚</span> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<span class="bottom-right-corner">╝</span> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: Recurring Tickets</span>
</div>
<button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW RECURRING TICKET</button>
</div>
<div class="ascii-section-header">Recurring Tickets Management</div> <div class="lt-frame">
<div class="ascii-content"> <span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="ascii-frame-inner"> <div class="lt-section-header">Scheduled Tickets</div>
<div class="admin-header-row"> <div class="lt-section-body">
<h2>Scheduled Tickets</h2> <p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
<button data-action="show-create-modal" class="btn">+ NEW RECURRING TICKET</button> Recurring tickets are automatically created on a schedule. Use <code>{{date}}</code>, <code>{{month}}</code>, <code>{{year}}</code> in title templates.
</div> </p>
<div class="lt-table-wrap">
<div class="table-wrapper"> <table class="lt-table lt-table-responsive" aria-label="Recurring tickets">
<table> <thead>
<thead> <tr>
<tr> <th scope="col">Title Template</th>
<th>ID</th> <th scope="col">Schedule</th>
<th>Title Template</th> <th scope="col">Category</th>
<th>Schedule</th> <th scope="col">Assigned To</th>
<th>Category</th> <th scope="col">Next Run</th>
<th>Assigned To</th> <th scope="col">Status</th>
<th>Next Run</th> <th scope="col">Actions</th>
<th>Status</th> </tr>
<th>Actions</th> </thead>
</tr> <tbody>
</thead> <?php if (empty($recurringTickets)): ?>
<tbody> <tr><td colspan="7" class="lt-empty">No recurring tickets configured. Create schedules to auto-generate tickets.</td></tr>
<?php if (empty($recurringTickets)): ?> <?php else: foreach ($recurringTickets as $rt): ?>
<tr> <?php
<td colspan="8" class="empty-state">No recurring tickets configured.</td> $schedule = ucfirst($rt['schedule_type']);
</tr> if ($rt['schedule_type'] === 'weekly') {
<?php else: ?> $days = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
<?php foreach ($recurringTickets as $rt): ?> $schedule .= ' (' . ($days[$rt['schedule_day']] ?? '?') . ')';
<tr> } elseif ($rt['schedule_type'] === 'monthly') {
<td><?php echo $rt['recurring_id']; ?></td> $schedule .= ' (Day ' . $rt['schedule_day'] . ')';
<td><?php echo htmlspecialchars($rt['title_template']); ?></td>
<td>
<?php
$schedule = ucfirst($rt['schedule_type']);
if ($rt['schedule_type'] === 'weekly') {
$days = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
$schedule .= ' (' . ($days[$rt['schedule_day']] ?? '?') . ')';
} elseif ($rt['schedule_type'] === 'monthly') {
$schedule .= ' (Day ' . $rt['schedule_day'] . ')';
}
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
echo htmlspecialchars($schedule);
?>
</td>
<td><?php echo htmlspecialchars($rt['category']); ?></td>
<td><?php echo htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned'); ?></td>
<td class="nowrap"><?php echo date('M d, Y H:i', strtotime($rt['next_run_at'])); ?></td>
<td>
<span class="<?php echo $rt['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $rt['is_active'] ? 'Active' : 'Inactive'; ?>
</span>
</td>
<td>
<button data-action="edit-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="toggle-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">
<?php echo $rt['is_active'] ? 'DISABLE' : 'ENABLE'; ?>
</button>
<button data-action="delete-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal lt-modal-lg">
<div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Recurring Ticket</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<form id="recurringForm">
<input type="hidden" id="recurring_id" name="recurring_id">
<div class="lt-modal-body">
<div class="setting-row">
<label for="title_template">Title Template *</label>
<input type="text" id="title_template" name="title_template" required placeholder="Use {{date}}, {{month}}, etc.">
</div>
<div class="setting-row">
<label for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" rows="8"></textarea>
</div>
<div class="setting-row">
<label for="schedule_type">Schedule Type *</label>
<select id="schedule_type" name="schedule_type" required data-action="update-schedule-options">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div class="setting-row is-hidden" id="schedule_day_row">
<label for="schedule_day">Schedule Day</label>
<select id="schedule_day" name="schedule_day"></select>
</div>
<div class="setting-row">
<label for="schedule_time">Schedule Time *</label>
<input type="time" id="schedule_time" name="schedule_time" value="09:00" required>
</div>
<div class="setting-grid-2">
<div class="setting-row setting-row-compact">
<label for="category">Category</label>
<select id="category" name="category">
<option value="General">General</option>
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="type">Type</label>
<select id="type" name="type">
<option value="Issue">Issue</option>
<option value="Maintenance">Maintenance</option>
<option value="Install">Install</option>
<option value="Task">Task</option>
<option value="Upgrade">Upgrade</option>
<option value="Problem">Problem</option>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="priority">Priority</label>
<select id="priority" name="priority">
<option value="1">P1 - Critical</option>
<option value="2">P2 - High</option>
<option value="3">P3 - Medium</option>
<option value="4" selected>P4 - Low</option>
<option value="5">P5 - Lowest</option>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="assigned_to">Assign To</label>
<select id="assigned_to" name="assigned_to">
<option value="">Unassigned</option>
<!-- Populated by JavaScript -->
</select>
</div>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>">
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
document.getElementById('recurringForm').reset();
document.getElementById('recurring_id').value = '';
updateScheduleOptions();
lt.modal.open('recurringModal');
}
function closeModal() {
lt.modal.close('recurringModal');
}
// Event delegation for data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'show-create-modal':
showCreateModal();
break;
case 'edit-recurring':
editRecurring(target.dataset.id);
break;
case 'toggle-recurring':
toggleRecurring(target.dataset.id);
break;
case 'delete-recurring':
deleteRecurring(target.dataset.id);
break;
} }
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
?>
<tr>
<td data-label="Title"><strong><?= htmlspecialchars($rt['title_template']) ?></strong></td>
<td data-label="Schedule" class="lt-text-xs lt-text-cyan"><?= htmlspecialchars($schedule) ?></td>
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($rt['category']) ?></td>
<td data-label="Assigned To" class="lt-text-xs">
<?= htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned') ?>
</td>
<td data-label="Next Run" class="lt-text-xs lt-text-muted">
<?= date('M d, Y H:i', strtotime($rt['next_run_at'])) ?>
</td>
<td data-label="Status">
<span class="lt-status <?= $rt['is_active'] ? 'lt-status-open' : 'lt-status-closed' ?>">
<?= $rt['is_active'] ? 'Active' : 'Inactive' ?>
</span>
</td>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-recurring" data-id="<?= $rt['recurring_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm"
data-action="toggle-recurring" data-id="<?= $rt['recurring_id'] ?>">
<?= $rt['is_active'] ? 'PAUSE' : 'RESUME' ?>
</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-recurring" data-id="<?= $rt['recurring_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; endif ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="recModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="recModalTitle">Create Recurring Ticket</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="recurringForm">
<input type="hidden" id="recurring_id" name="recurring_id">
<div class="lt-modal-body">
<div class="lt-form-group">
<label class="lt-label" for="rec_title_template">Title Template *</label>
<input type="text" id="rec_title_template" name="title_template" class="lt-input" required
placeholder="Use {{date}}, {{month}}, {{year}}">
</div>
<div class="lt-form-group">
<label class="lt-label" for="rec_description_template">Description Template</label>
<textarea id="rec_description_template" name="description_template"
class="lt-input lt-textarea" rows="6"></textarea>
</div>
<div class="lt-form-group">
<label class="lt-label" for="schedule_type">Schedule Type *</label>
<select id="schedule_type" name="schedule_type" class="lt-select" required
data-action="update-schedule-options">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div class="lt-form-group is-hidden" id="schedule_day_row">
<label class="lt-label" for="schedule_day">Schedule Day</label>
<select id="schedule_day" name="schedule_day" class="lt-select"></select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="schedule_time">Schedule Time *</label>
<input type="time" id="schedule_time" name="schedule_time" class="lt-input"
value="09:00" required style="max-width:12rem">
</div>
<div class="create-ticket-meta-grid">
<div class="lt-form-group">
<label class="lt-label" for="rec-category">Category</label>
<select id="rec-category" name="category" class="lt-select">
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
<option value="<?= $c ?>"><?= $c ?></option>
<?php endforeach ?>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="rec-type">Type</label>
<select id="rec-type" name="type" class="lt-select">
<?php foreach (['Issue','Maintenance','Install','Task','Upgrade','Problem'] as $t): ?>
<option value="<?= $t ?>"><?= $t ?></option>
<?php endforeach ?>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="rec-priority">Priority</label>
<select id="rec-priority" name="priority" class="lt-select">
<option value="1">P1 — Critical</option>
<option value="2">P2 — High</option>
<option value="3">P3 — Medium</option>
<option value="4" selected>P4 — Low</option>
<option value="5">P5 — Lowest</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="assigned_to">Assign To</label>
<select id="assigned_to" name="assigned_to" class="lt-select">
<option value="">Unassigned</option>
<!-- Populated by JS -->
</select>
</div>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?= $nonce ?>">
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
switch (target.getAttribute('data-action')) {
case 'show-create-modal': showCreateModal(); break;
case 'edit-recurring': editRecurring(target.getAttribute('data-id')); break;
case 'toggle-recurring': toggleRecurring(target.getAttribute('data-id')); break;
case 'delete-recurring': deleteRecurring(target.getAttribute('data-id')); break;
}
});
document.addEventListener('change', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
if (target.getAttribute('data-action') === 'update-schedule-options') updateScheduleOptions();
});
document.getElementById('recurringForm').addEventListener('submit', function (e) {
saveRecurring(e);
});
if (window.lt) lt.keys.initDefaults();
function updateScheduleOptions() {
var type = document.getElementById('schedule_type').value;
var dayRow = document.getElementById('schedule_day_row');
var daySelect = document.getElementById('schedule_day');
daySelect.innerHTML = '';
if (type === 'daily') {
dayRow.classList.add('is-hidden');
} else if (type === 'weekly') {
dayRow.classList.remove('is-hidden');
['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'].forEach(function (day, i) {
var opt = document.createElement('option');
opt.value = String(i + 1);
opt.textContent = day;
daySelect.appendChild(opt);
}); });
} else if (type === 'monthly') {
dayRow.classList.remove('is-hidden');
for (var i = 1; i <= 28; i++) {
var opt = document.createElement('option');
opt.value = String(i);
opt.textContent = 'Day ' + i;
daySelect.appendChild(opt);
}
}
}
document.addEventListener('change', function(event) { function showCreateModal() {
const target = event.target.closest('[data-action]'); document.getElementById('recModalTitle').textContent = 'Create Recurring Ticket';
if (!target) return; document.getElementById('recurringForm').reset();
document.getElementById('recurring_id').value = '';
updateScheduleOptions();
lt.modal.open('recurringModal');
}
if (target.dataset.action === 'update-schedule-options') { function editRecurring(id) {
lt.api.get('/api/manage_recurring.php?id=' + id)
.then(function (data) {
if (data.success && data.recurring) {
var rt = data.recurring;
document.getElementById('recurring_id').value = rt.recurring_id;
document.getElementById('rec_title_template').value = rt.title_template;
document.getElementById('rec_description_template').value = rt.description_template || '';
document.getElementById('schedule_type').value = rt.schedule_type;
updateScheduleOptions(); updateScheduleOptions();
document.getElementById('schedule_day').value = rt.schedule_day || '';
document.getElementById('schedule_time').value = rt.schedule_time ? rt.schedule_time.substring(0, 5) : '09:00';
document.getElementById('rec-category').value = rt.category || 'General';
document.getElementById('rec-type').value = rt.type || 'Issue';
document.getElementById('rec-priority').value = rt.priority || 4;
document.getElementById('assigned_to').value = rt.assigned_to || '';
document.getElementById('recModalTitle').textContent = 'Edit Recurring Ticket';
lt.modal.open('recurringModal');
} else {
lt.toast.error(data.error || 'Failed to load schedule');
} }
}); }).catch(function () { lt.toast.error('Failed to load schedule'); });
}
// Form submit handler function toggleRecurring(id) {
document.getElementById('recurringForm').addEventListener('submit', function(e) { lt.api.post('/api/manage_recurring.php?action=toggle&id=' + id)
saveRecurring(e); .then(function (data) {
}); if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to toggle');
}).catch(function () { lt.toast.error('Failed to toggle'); });
}
if (window.lt) lt.keys.initDefaults(); function deleteRecurring(id) {
showConfirmModal('Delete Schedule', 'Delete this recurring ticket schedule? This cannot be undone.', 'error', function () {
function updateScheduleOptions() { lt.api.delete('/api/manage_recurring.php?id=' + id)
const type = document.getElementById('schedule_type').value; .then(function (data) {
const dayRow = document.getElementById('schedule_day_row');
const daySelect = document.getElementById('schedule_day');
daySelect.innerHTML = '';
if (type === 'daily') {
dayRow.classList.add('is-hidden');
} else if (type === 'weekly') {
dayRow.classList.remove('is-hidden');
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
days.forEach((day, i) => {
daySelect.innerHTML += `<option value="${i + 1}">${day}</option>`;
});
} else if (type === 'monthly') {
dayRow.classList.remove('is-hidden');
for (let i = 1; i <= 28; i++) {
daySelect.innerHTML += `<option value="${i}">Day ${i}</option>`;
}
}
}
function saveRecurring(e) {
e.preventDefault();
const form = new FormData(document.getElementById('recurringForm'));
const data = Object.fromEntries(form);
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);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
lt.toast.error(result.error || 'Failed to save');
}
}).catch(err => lt.toast.error('Failed to save'));
}
function toggleRecurring(id) {
lt.api.post('/api/manage_recurring.php?action=toggle&id=' + id)
.then(data => {
if (data.success) window.location.reload(); if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to toggle'); else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to toggle')); }).catch(function () { lt.toast.error('Failed to delete'); });
} });
}
function deleteRecurring(id) { function saveRecurring(e) {
showConfirmModal('Delete Schedule', 'Delete this recurring ticket schedule?', 'error', function() { e.preventDefault();
lt.api.delete('/api/manage_recurring.php?id=' + id) var form = new FormData(document.getElementById('recurringForm'));
.then(data => { var data = {};
if (data.success) window.location.reload(); form.forEach(function (v, k) { data[k] = v; });
else lt.toast.error(data.error || 'Failed to delete'); var url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : '');
}).catch(err => lt.toast.error('Failed to delete')); var apiCall = data.recurring_id ? lt.api.put(url, data) : lt.api.post(url, data);
}); apiCall.then(function (result) {
} if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
function editRecurring(id) { function loadUsers() {
lt.api.get('/api/manage_recurring.php?id=' + id) lt.api.get('/api/get_users.php')
.then(data => { .then(function (data) {
if (data.success && data.recurring) { if (data.success && data.users) {
const rt = data.recurring; var select = document.getElementById('assigned_to');
document.getElementById('recurring_id').value = rt.recurring_id; data.users.forEach(function (user) {
document.getElementById('title_template').value = rt.title_template; var opt = document.createElement('option');
document.getElementById('description_template').value = rt.description_template || ''; opt.value = user.user_id;
document.getElementById('schedule_type').value = rt.schedule_type; opt.textContent = user.display_name || user.username;
updateScheduleOptions(); select.appendChild(opt);
document.getElementById('schedule_day').value = rt.schedule_day || '';
document.getElementById('schedule_time').value = rt.schedule_time ? rt.schedule_time.substring(0, 5) : '09:00';
document.getElementById('category').value = rt.category || 'General';
document.getElementById('type').value = rt.type || 'Issue';
document.getElementById('priority').value = rt.priority || 4;
document.getElementById('assigned_to').value = rt.assigned_to || '';
document.getElementById('modalTitle').textContent = 'Edit Recurring Ticket';
lt.modal.open('recurringModal');
}
}); });
} }
}).catch(function () { /* non-critical: assigned_to stays as manual input */ });
}
// Load users for assignee dropdown updateScheduleOptions();
function loadUsers() { loadUsers();
lt.api.get('/api/get_users.php') </script>
.then(data => {
if (data.success && data.users) {
const select = document.getElementById('assigned_to');
data.users.forEach(user => {
const option = document.createElement('option');
option.value = user.user_id;
option.textContent = user.display_name || user.username;
select.appendChild(option);
});
}
});
}
// Initialize <?php include __DIR__ . '/../../views/layout_footer.php'; ?>
updateScheduleOptions();
loadUsers();
</script>
</body>
</html>
+195 -241
View File
@@ -1,258 +1,212 @@
<?php <?php
// Admin view for managing ticket templates
// Receives $templates from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Templates';
$activeNav = 'admin-templates';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en"> <div class="lt-page-header">
<head> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<meta charset="UTF-8"> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <span class="lt-text-muted lt-text-xs">/</span>
<title>Template Management - Admin</title> <span class="lt-text-muted lt-text-xs">Admin: Templates</span>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> </div>
<link rel="stylesheet" href="/assets/css/base.css"> <button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW TEMPLATE</button>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <div class="lt-frame">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script> <span class="lt-frame-bl">╚</span><span class="lt-frame-br"></span>
<script nonce="<?php echo $nonce; ?>"> <div class="lt-section-header">Ticket Template Management</div>
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; <div class="lt-section-body">
</script> <p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
</head> Templates pre-fill ticket creation forms with standard content for common ticket types.
<body> </p>
<div class="user-header"> <div class="lt-table-wrap">
<div class="user-header-left"> <table class="lt-table lt-table-responsive" aria-label="Ticket templates">
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <thead>
<span class="admin-page-title">Admin: Templates</span> <tr>
</div> <th scope="col">Template Name</th>
<div class="user-header-right"> <th scope="col">Category</th>
<?php if (isset($GLOBALS['currentUser'])): ?> <th scope="col">Type</th>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span> <th scope="col">Priority</th>
<span class="admin-badge">[ ADMIN ]</span> <th scope="col">Status</th>
<?php endif; ?> <th scope="col">Actions</th>
</div> </tr>
</thead>
<tbody>
<?php if (empty($templates)): ?>
<tr><td colspan="6" class="lt-empty">No templates defined. Create templates to speed up ticket creation.</td></tr>
<?php else: foreach ($templates as $tpl): ?>
<tr>
<td data-label="Name"><strong><?= htmlspecialchars($tpl['template_name']) ?></strong></td>
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($tpl['category'] ?? 'Any') ?></td>
<td data-label="Type" class="lt-text-xs"><?= htmlspecialchars($tpl['type'] ?? 'Any') ?></td>
<?php $tp = (int)($tpl['default_priority'] ?? 4); $tBadge = match($tp) { 1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4' }; ?>
<td data-label="Priority"><span class="lt-badge <?= $tBadge ?>">P<?= $tp ?></span></td>
<td data-label="Status">
<span class="lt-status <?= ($tpl['is_active'] ?? 1) ? 'lt-status-open' : 'lt-status-closed' ?>">
<?= ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive' ?>
</span>
</td>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-template" data-id="<?= $tpl['template_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-template" data-id="<?= $tpl['template_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; endif ?>
</tbody>
</table>
</div> </div>
</div>
</div>
<div class="ascii-frame-outer admin-container"> <!-- Create/Edit Modal -->
<span class="bottom-left-corner">╚</span> <div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog"
<span class="bottom-right-corner">╝</span> aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal">
<div class="ascii-section-header">Ticket Template Management</div> <div class="lt-modal-header">
<div class="ascii-content"> <span class="lt-modal-title" id="modalTitle">Create Template</span>
<div class="ascii-frame-inner"> <button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
<div class="admin-header-row">
<h2>Ticket Templates</h2>
<button data-action="show-create-modal" class="btn">+ NEW TEMPLATE</button>
</div>
<p class="text-muted-green mb-1">
Templates pre-fill ticket creation forms with standard content for common ticket types.
</p>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Template Name</th>
<th>Category</th>
<th>Type</th>
<th>Priority</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($templates)): ?>
<tr>
<td colspan="6" class="empty-state">No templates defined. Create templates to speed up ticket creation.</td>
</tr>
<?php else: ?>
<?php foreach ($templates as $tpl): ?>
<tr>
<td><strong><?php echo htmlspecialchars($tpl['template_name']); ?></strong></td>
<td><?php echo htmlspecialchars($tpl['category'] ?? 'Any'); ?></td>
<td><?php echo htmlspecialchars($tpl['type'] ?? 'Any'); ?></td>
<td>P<?php echo $tpl['default_priority'] ?? '4'; ?></td>
<td>
<span class="<?php echo ($tpl['is_active'] ?? 1) ? 'text-open' : 'text-closed'; ?>">
<?php echo ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive'; ?>
</span>
</td>
<td>
<button data-action="edit-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="delete-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
<form id="templateForm">
<!-- Create/Edit Modal --> <input type="hidden" id="template_id" name="template_id">
<div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle"> <div class="lt-modal-body">
<div class="lt-modal lt-modal-lg"> <div class="lt-form-group">
<div class="lt-modal-header"> <label class="lt-label" for="template_name">Template Name *</label>
<span class="lt-modal-title" id="modalTitle">Create Template</span> <input type="text" id="template_name" name="template_name" class="lt-input" required>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<form id="templateForm">
<input type="hidden" id="template_id" name="template_id">
<div class="lt-modal-body">
<div class="setting-row">
<label for="template_name">Template Name *</label>
<input type="text" id="template_name" name="template_name" required>
</div>
<div class="setting-row">
<label for="title_template">Title Template</label>
<input type="text" id="title_template" name="title_template" placeholder="Pre-filled title text">
</div>
<div class="setting-row">
<label for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" rows="10" placeholder="Pre-filled description content"></textarea>
</div>
<div class="setting-grid-3">
<div class="setting-row setting-row-compact">
<label for="category">Category</label>
<select id="category" name="category">
<option value="">Any</option>
<option value="General">General</option>
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="type">Type</label>
<select id="type" name="type">
<option value="">Any</option>
<option value="Maintenance">Maintenance</option>
<option value="Install">Install</option>
<option value="Task">Task</option>
<option value="Upgrade">Upgrade</option>
<option value="Issue">Issue</option>
<option value="Problem">Problem</option>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="priority">Priority</label>
<select id="priority" name="priority">
<option value="1">P1</option>
<option value="2">P2</option>
<option value="3">P3</option>
<option value="4" selected>P4</option>
<option value="5">P5</option>
</select>
</div>
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div> </div>
</div> <div class="lt-form-group">
<label class="lt-label" for="title_template">Title Template</label>
<input type="text" id="title_template" name="title_template" class="lt-input"
placeholder="Pre-filled title text">
</div>
<div class="lt-form-group">
<label class="lt-label" for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" class="lt-input lt-textarea"
rows="10" placeholder="Pre-filled description content"></textarea>
</div>
<div class="create-ticket-meta-grid">
<div class="lt-form-group">
<label class="lt-label" for="tpl-category">Category</label>
<select id="tpl-category" name="category" class="lt-select">
<option value="">Any</option>
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
<option value="<?= $c ?>"><?= $c ?></option>
<?php endforeach ?>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="tpl-type">Type</label>
<select id="tpl-type" name="type" class="lt-select">
<option value="">Any</option>
<?php foreach (['Maintenance','Install','Task','Upgrade','Issue','Problem'] as $t): ?>
<option value="<?= $t ?>"><?= $t ?></option>
<?php endforeach ?>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="tpl-priority">Priority</label>
<select id="tpl-priority" name="priority" class="lt-select">
<?php foreach ([1=>'P1',2=>'P2',3=>'P3',4=>'P4 (default)',5=>'P5'] as $v=>$l): ?>
<option value="<?= $v ?>" <?= $v === 4 ? 'selected' : '' ?>><?= $l ?></option>
<?php endforeach ?>
</select>
</div>
</div>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="is_active" name="is_active" checked>
Active
</label>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?= $nonce ?>">
const templates = <?php echo json_encode($templates ?? []); ?>; var templates = <?= json_encode($templates ?? [], JSON_HEX_TAG) ?>;
function showCreateModal() { document.addEventListener('click', function (e) {
document.getElementById('modalTitle').textContent = 'Create Template'; var target = e.target.closest('[data-action]');
document.getElementById('templateForm').reset(); if (!target) return;
document.getElementById('template_id').value = ''; switch (target.getAttribute('data-action')) {
document.getElementById('is_active').checked = true; case 'show-create-modal': showCreateModal(); break;
lt.modal.open('templateModal'); case 'edit-template': editTemplate(target.getAttribute('data-id')); break;
} case 'delete-template': deleteTemplate(target.getAttribute('data-id')); break;
}
});
function closeModal() { document.getElementById('templateForm').addEventListener('submit', function (e) {
lt.modal.close('templateModal'); saveTemplate(e);
} });
// Event delegation for data-action handlers if (window.lt) lt.keys.initDefaults();
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action; function showCreateModal() {
switch (action) { document.getElementById('modalTitle').textContent = 'Create Template';
case 'show-create-modal': document.getElementById('templateForm').reset();
showCreateModal(); document.getElementById('template_id').value = '';
break; document.getElementById('is_active').checked = true;
case 'edit-template': lt.modal.open('templateModal');
editTemplate(target.dataset.id); }
break;
case 'delete-template':
deleteTemplate(target.dataset.id);
break;
}
});
// Form submit handler function editTemplate(id) {
document.getElementById('templateForm').addEventListener('submit', function(e) { var tpl = templates.find(function (t) { return t.template_id == id; });
saveTemplate(e); if (!tpl) return;
}); document.getElementById('template_id').value = tpl.template_id;
document.getElementById('template_name').value = tpl.template_name;
document.getElementById('title_template').value = tpl.title_template || '';
document.getElementById('description_template').value = tpl.description_template || '';
document.getElementById('tpl-category').value = tpl.category || '';
document.getElementById('tpl-type').value = tpl.type || '';
document.getElementById('tpl-priority').value = tpl.default_priority || 4;
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
document.getElementById('modalTitle').textContent = 'Edit Template';
lt.modal.open('templateModal');
}
if (window.lt) lt.keys.initDefaults(); function deleteTemplate(id) {
showConfirmModal('Delete Template', 'Delete this template? This cannot be undone.', 'error', function () {
lt.api.delete('/api/manage_templates.php?id=' + id)
.then(function (data) {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(function () { lt.toast.error('Failed to delete'); });
});
}
function saveTemplate(e) { function saveTemplate(e) {
e.preventDefault(); e.preventDefault();
const data = { var data = {
template_id: document.getElementById('template_id').value, template_id: document.getElementById('template_id').value,
template_name: document.getElementById('template_name').value, template_name: document.getElementById('template_name').value,
title_template: document.getElementById('title_template').value, title_template: document.getElementById('title_template').value,
description_template: document.getElementById('description_template').value, description_template: document.getElementById('description_template').value,
category: document.getElementById('category').value || null, category: document.getElementById('tpl-category').value || null,
type: document.getElementById('type').value || null, type: document.getElementById('tpl-type').value || null,
default_priority: parseInt(document.getElementById('priority').value) || 4, default_priority: parseInt(document.getElementById('tpl-priority').value) || 4,
is_active: document.getElementById('is_active').checked ? 1 : 0 is_active: document.getElementById('is_active').checked ? 1 : 0,
}; };
var url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : '');
var apiCall = data.template_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(function (result) {
if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
</script>
const url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : ''); <?php include __DIR__ . '/../../views/layout_footer.php'; ?>
const apiCall = data.template_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
lt.toast.error(result.error || 'Failed to save');
}
}).catch(err => lt.toast.error('Failed to save'));
}
function editTemplate(id) {
const tpl = templates.find(t => t.template_id == id);
if (!tpl) return;
document.getElementById('template_id').value = tpl.template_id;
document.getElementById('template_name').value = tpl.template_name;
document.getElementById('title_template').value = tpl.title_template || '';
document.getElementById('description_template').value = tpl.description_template || '';
document.getElementById('category').value = tpl.category || '';
document.getElementById('type').value = tpl.type || '';
document.getElementById('priority').value = tpl.default_priority || 4;
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
document.getElementById('modalTitle').textContent = 'Edit Template';
lt.modal.open('templateModal');
}
function deleteTemplate(id) {
showConfirmModal('Delete Template', 'Delete this template?', 'error', function() {
lt.api.delete('/api/manage_templates.php?id=' + id)
.then(data => {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
});
}
</script>
</body>
</html>
+107 -126
View File
@@ -1,135 +1,116 @@
<?php <?php
// Admin view for user activity reports
// Receives $userStats, $dateRange from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'User Activity';
$activeNav = 'admin-user-activity';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en"> <div class="lt-page-header">
<head> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<meta charset="UTF-8"> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <span class="lt-text-muted lt-text-xs">/</span>
<title>User Activity - Admin</title> <span class="lt-text-muted lt-text-xs">Admin: User Activity</span>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> </div>
<link rel="stylesheet" href="/assets/css/base.css"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320"> <div class="lt-frame">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <span class="lt-frame-bl">╚</span><span class="lt-frame-br"></span>
<script nonce="<?php echo $nonce; ?>"> <div class="lt-section-header">User Activity Report</div>
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; <div class="lt-section-body">
</script>
</head> <!-- Date filter -->
<body> <form method="GET" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-mb-md" role="search">
<div class="user-header"> <div class="lt-form-group" style="margin:0">
<div class="user-header-left"> <label class="lt-label" for="date_from">Date From</label>
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <input type="date" name="date_from" id="date_from" class="lt-input lt-input-sm"
<span class="admin-page-title">Admin: User Activity</span> value="<?= htmlspecialchars($dateRange['from'] ?? '') ?>">
</div>
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="date_to">Date To</label>
<input type="date" name="date_to" id="date_to" class="lt-input lt-input-sm"
value="<?= htmlspecialchars($dateRange['to'] ?? '') ?>">
</div>
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center" style="align-self:flex-end">
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">APPLY</button>
<a href="?" class="lt-btn lt-btn-ghost lt-btn-sm">RESET</a>
</div>
</form>
<!-- Summary stats -->
<?php if (!empty($userStats)): ?>
<div class="lt-stats-grid lt-mb-md">
<div class="lt-stat-card">
<div class="lt-stat-icon lt-text-cyan">[ # ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= count($userStats) ?></div>
<div class="lt-stat-label">Active Users</div>
</div> </div>
<div class="user-header-right"> </div>
<?php if (isset($GLOBALS['currentUser'])): ?> <div class="lt-stat-card">
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span> <div class="lt-stat-icon">[ + ]</div>
<span class="admin-badge">[ ADMIN ]</span> <div class="lt-stat-info">
<?php endif; ?> <div class="lt-stat-value"><?= array_sum(array_column($userStats, 'tickets_created')) ?></div>
<div class="lt-stat-label">Total Created</div>
</div> </div>
</div>
<div class="lt-stat-card">
<div class="lt-stat-icon lt-text-muted">[ OK ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= array_sum(array_column($userStats, 'tickets_resolved')) ?></div>
<div class="lt-stat-label">Total Resolved</div>
</div>
</div>
<div class="lt-stat-card">
<div class="lt-stat-icon lt-text-amber">[ > ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= array_sum(array_column($userStats, 'comments_added')) ?></div>
<div class="lt-stat-label">Total Comments</div>
</div>
</div>
</div> </div>
<?php endif ?>
<div class="ascii-frame-outer admin-container"> <!-- User activity table -->
<span class="bottom-left-corner">╚</span> <div class="lt-table-wrap">
<span class="bottom-right-corner">╝</span> <table class="lt-table lt-table-responsive" aria-label="User activity">
<thead>
<div class="ascii-section-header">User Activity Report</div> <tr>
<div class="ascii-content"> <th scope="col">User</th>
<div class="ascii-frame-inner"> <th scope="col">Tickets Created</th>
<!-- Date Range Filter --> <th scope="col">Tickets Resolved</th>
<form method="GET" class="admin-form-row"> <th scope="col">Comments</th>
<div class="admin-form-field"> <th scope="col">Assigned</th>
<label class="admin-label" for="date_from">Date From</label> <th scope="col">Last Activity</th>
<input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="admin-input"> </tr>
</div> </thead>
<div class="admin-form-field"> <tbody>
<label class="admin-label" for="date_to">Date To</label> <?php if (empty($userStats)): ?>
<input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($dateRange['to'] ?? ''); ?>" class="admin-input"> <tr><td colspan="6" class="lt-empty">No user activity data available.</td></tr>
</div> <?php else: foreach ($userStats as $u): ?>
<div class="admin-form-actions"> <tr>
<button type="submit" class="btn">APPLY</button> <td data-label="User">
<a href="?" class="btn btn-secondary">RESET</a> <strong><?= htmlspecialchars($u['display_name'] ?? $u['username']) ?></strong>
</div> <?php if ($u['is_admin']): ?>
</form> <span class="lt-badge lt-badge-admin lt-text-xs">ADMIN</span>
<?php endif ?>
<!-- User Activity Table --> </td>
<div class="table-wrapper"> <td data-label="Created"><span class="lt-text-cyan"><?= (int)($u['tickets_created'] ?? 0) ?></span></td>
<table> <td data-label="Resolved"><span class="lt-text-muted"><?= (int)($u['tickets_resolved'] ?? 0) ?></span></td>
<thead> <td data-label="Comments"><span class="lt-text-amber"><?= (int)($u['comments_added'] ?? 0) ?></span></td>
<tr> <td data-label="Assigned"><?= (int)($u['tickets_assigned'] ?? 0) ?></td>
<th>User</th> <td data-label="Last Activity" class="lt-text-xs lt-text-muted">
<th class="text-center">Tickets Created</th> <?= $u['last_activity'] ? date('M d, Y H:i', strtotime($u['last_activity'])) : 'Never' ?>
<th class="text-center">Tickets Resolved</th> </td>
<th class="text-center">Comments Added</th> </tr>
<th class="text-center">Tickets Assigned</th> <?php endforeach; endif ?>
<th class="text-center">Last Activity</th> </tbody>
</tr> </table>
</thead>
<tbody>
<?php if (empty($userStats)): ?>
<tr>
<td colspan="6" class="empty-state">No user activity data available.</td>
</tr>
<?php else: ?>
<?php foreach ($userStats as $user): ?>
<tr>
<td>
<strong><?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?></strong>
<?php if ($user['is_admin']): ?>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</td>
<td class="text-center">
<span class="text-green fw-bold"><?php echo $user['tickets_created'] ?? 0; ?></span>
</td>
<td class="text-center">
<span class="text-open fw-bold"><?php echo $user['tickets_resolved'] ?? 0; ?></span>
</td>
<td class="text-center">
<span class="text-cyan fw-bold"><?php echo $user['comments_added'] ?? 0; ?></span>
</td>
<td class="text-center">
<span class="text-amber fw-bold"><?php echo $user['tickets_assigned'] ?? 0; ?></span>
</td>
<td class="text-center text-sm">
<?php echo $user['last_activity'] ? date('M d, Y H:i', strtotime($user['last_activity'])) : 'Never'; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Summary Stats -->
<?php if (!empty($userStats)): ?>
<div class="admin-stats-grid">
<div>
<div class="admin-stat-value text-green"><?php echo array_sum(array_column($userStats, 'tickets_created')); ?></div>
<div class="admin-stat-label">Total Created</div>
</div>
<div>
<div class="admin-stat-value text-open"><?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?></div>
<div class="admin-stat-label">Total Resolved</div>
</div>
<div>
<div class="admin-stat-value text-cyan"><?php echo array_sum(array_column($userStats, 'comments_added')); ?></div>
<div class="admin-stat-label">Total Comments</div>
</div>
<div>
<div class="admin-stat-value text-amber"><?php echo count($userStats); ?></div>
<div class="admin-stat-label">Active Users</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div> </div>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script> </div>
</body> </div>
</html>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+207 -251
View File
@@ -1,271 +1,227 @@
<?php <?php
// Admin view for workflow/status transitions designer
// Receives $workflows from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Workflow Designer';
$activeNav = 'admin-workflow';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en"> <div class="lt-page-header">
<head> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<meta charset="UTF-8"> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <span class="lt-text-muted lt-text-xs">/</span>
<title>Workflow Designer - Admin</title> <span class="lt-text-muted lt-text-xs">Admin: Workflow</span>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> </div>
<link rel="stylesheet" href="/assets/css/base.css"> <button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW TRANSITION</button>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <div class="lt-frame lt-mb-md">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script> <span class="lt-frame-bl">╚</span><span class="lt-frame-br"></span>
<script nonce="<?php echo $nonce; ?>"> <div class="lt-section-header">Workflow Diagram</div>
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; <div class="lt-section-body">
</script> <div class="lt-grid-4">
</head> <?php
<body> $statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
<div class="user-header"> foreach ($statuses as $status):
<div class="user-header-left"> $slug = strtolower(str_replace(' ', '-', $status));
<a href="/" class="back-link">[ ← DASHBOARD ]</a> $toCount = 0;
<span class="admin-page-title">Admin: Workflow Designer</span> if (isset($workflows)) { foreach ($workflows as $w) { if ($w['from_status'] === $status) $toCount++; } }
</div> ?>
<div class="user-header-right"> <div class="lt-card" style="text-align:center">
<?php if (isset($GLOBALS['currentUser'])): ?> <span class="lt-status lt-status-<?= $slug ?>"><?= $status ?></span>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span> <div class="lt-text-xs lt-text-muted lt-mt-sm"> <?= $toCount ?> transition<?= $toCount !== 1 ? 's' : '' ?></div>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div> </div>
<?php endforeach ?>
</div> </div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
Define which status transitions are allowed. This controls what options appear in the status dropdown on tickets.
</p>
</div>
</div>
<div class="ascii-frame-outer admin-container"> <div class="lt-frame">
<span class="bottom-left-corner"></span> <span class="lt-frame-bl">╚</span><span class="lt-frame-br"></span>
<span class="bottom-right-corner">╝</span> <div class="lt-section-header">Status Transitions</div>
<div class="lt-section-body">
<div class="ascii-section-header">Status Workflow Designer</div> <div class="lt-table-wrap">
<div class="ascii-content"> <table class="lt-table lt-table-responsive" aria-label="Status transitions">
<div class="ascii-frame-inner"> <thead>
<div class="admin-header-row"> <tr>
<h2>Status Transitions</h2> <th scope="col">From Status</th>
<button data-action="show-create-modal" class="btn">+ NEW TRANSITION</button> <th scope="col">&rarr;</th>
<th scope="col">To Status</th>
<th scope="col">Req. Comment</th>
<th scope="col">Req. Admin</th>
<th scope="col">Active</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($workflows)): ?>
<tr><td colspan="7" class="lt-empty">No transitions defined. Add transitions to enable status changes.</td></tr>
<?php else: foreach ($workflows as $wf): ?>
<?php $fromSlug = strtolower(str_replace(' ', '-', $wf['from_status'])); $toSlug = strtolower(str_replace(' ', '-', $wf['to_status'])); ?>
<tr>
<td data-label="From">
<span class="lt-status lt-status-<?= $fromSlug ?>"><?= htmlspecialchars($wf['from_status']) ?></span>
</td>
<td class="lt-text-amber lt-text-xs" style="text-align:center">&rarr;</td>
<td data-label="To">
<span class="lt-status lt-status-<?= $toSlug ?>"><?= htmlspecialchars($wf['to_status']) ?></span>
</td>
<td data-label="Req. Comment" style="text-align:center">
<?= $wf['requires_comment'] ? '<span class="lt-text-cyan">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td>
<td data-label="Req. Admin" style="text-align:center">
<?= $wf['requires_admin'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td>
<td data-label="Active" style="text-align:center">
<?= $wf['is_active']
? '<span class="lt-text-cyan">✓</span>'
: '<span class="lt-text-danger">✗</span>' ?>
</td>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-transition" data-id="<?= $wf['transition_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-transition" data-id="<?= $wf['transition_id'] ?>">DEL</button>
</div> </div>
</td>
<p class="text-muted-green mb-1"> </tr>
Define which status transitions are allowed. This controls what options appear in the status dropdown. <?php endforeach; endif ?>
</p> </tbody>
</table>
<!-- Visual Workflow Diagram -->
<div class="workflow-diagram">
<h4 class="admin-section-title">Workflow Diagram</h4>
<div class="workflow-diagram-nodes">
<?php
$statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
foreach ($statuses as $status):
$statusClass = 'status-' . str_replace(' ', '-', strtolower($status));
?>
<div class="workflow-diagram-node">
<div class="<?php echo $statusClass; ?>">
<?php echo $status; ?>
</div>
<div class="text-muted-green workflow-diagram-node-label">
<?php
$toCount = 0;
if (isset($workflows)) {
foreach ($workflows as $w) {
if ($w['from_status'] === $status) $toCount++;
}
}
echo "→ $toCount transitions";
?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Transitions Table -->
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>From Status</th>
<th>→</th>
<th>To Status</th>
<th>Requires Comment</th>
<th>Requires Admin</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($workflows)): ?>
<tr>
<td colspan="7" class="empty-state">No transitions defined. Add transitions to enable status changes.</td>
</tr>
<?php else: ?>
<?php foreach ($workflows as $wf): ?>
<tr>
<td>
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['from_status'])); ?>">
<?php echo htmlspecialchars($wf['from_status']); ?>
</span>
</td>
<td class="text-amber text-center">→</td>
<td>
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['to_status'])); ?>">
<?php echo htmlspecialchars($wf['to_status']); ?>
</span>
</td>
<td class="text-center"><?php echo $wf['requires_comment'] ? '✓' : ''; ?></td>
<td class="text-center"><?php echo $wf['requires_admin'] ? '✓' : ''; ?></td>
<td class="text-center">
<span class="<?php echo $wf['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $wf['is_active'] ? '✓' : '✗'; ?>
</span>
</td>
<td>
<button data-action="edit-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="delete-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
</div>
</div>
<!-- Create/Edit Modal --> <!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle"> <div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog"
<div class="lt-modal lt-modal-sm"> aria-modal="true" aria-labelledby="wfModalTitle">
<div class="lt-modal-header"> <div class="lt-modal">
<span class="lt-modal-title" id="modalTitle">Create Transition</span> <div class="lt-modal-header">
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button> <span class="lt-modal-title" id="wfModalTitle">Create Transition</span>
</div> <button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
<form id="workflowForm">
<input type="hidden" id="transition_id" name="transition_id">
<div class="lt-modal-body">
<div class="setting-row">
<label for="from_status">From Status *</label>
<select id="from_status" name="from_status" required>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="setting-row">
<label for="to_status">To Status *</label>
<select id="to_status" name="to_status" required>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="setting-row">
<label><input type="checkbox" id="requires_comment" name="requires_comment"> Requires comment</label>
</div>
<div class="setting-row">
<label><input type="checkbox" id="requires_admin" name="requires_admin"> Requires admin privileges</label>
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div> </div>
<form id="workflowForm">
<input type="hidden" id="transition_id" name="transition_id">
<div class="lt-modal-body">
<div class="lt-form-group">
<label class="lt-label" for="from_status">From Status *</label>
<select id="from_status" name="from_status" class="lt-select" required>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="to_status">To Status *</label>
<select id="to_status" name="to_status" class="lt-select" required>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="requires_comment" name="requires_comment">
Requires a comment when transitioning
</label>
</div>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="requires_admin" name="requires_admin">
Requires administrator privileges
</label>
</div>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="wf_is_active" name="is_active" checked>
Active
</label>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?= $nonce ?>">
const workflows = <?php echo json_encode($workflows ?? []); ?>; var workflows = <?= json_encode($workflows ?? [], JSON_HEX_TAG) ?>;
function showCreateModal() { document.addEventListener('click', function (e) {
document.getElementById('modalTitle').textContent = 'Create Transition'; var target = e.target.closest('[data-action]');
document.getElementById('workflowForm').reset(); if (!target) return;
document.getElementById('transition_id').value = ''; switch (target.getAttribute('data-action')) {
document.getElementById('is_active').checked = true; case 'show-create-modal': showCreateModal(); break;
lt.modal.open('workflowModal'); case 'edit-transition': editTransition(target.getAttribute('data-id')); break;
} case 'delete-transition': deleteTransition(target.getAttribute('data-id')); break;
}
});
function closeModal() { document.getElementById('workflowForm').addEventListener('submit', function (e) {
lt.modal.close('workflowModal'); saveTransition(e);
} });
// Event delegation for data-action handlers if (window.lt) lt.keys.initDefaults();
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action; function showCreateModal() {
switch (action) { document.getElementById('wfModalTitle').textContent = 'Create Transition';
case 'show-create-modal': document.getElementById('workflowForm').reset();
showCreateModal(); document.getElementById('transition_id').value = '';
break; document.getElementById('wf_is_active').checked = true;
case 'edit-transition': lt.modal.open('workflowModal');
editTransition(target.dataset.id); }
break;
case 'delete-transition':
deleteTransition(target.dataset.id);
break;
}
});
// Form submit handler function editTransition(id) {
document.getElementById('workflowForm').addEventListener('submit', function(e) { var wf = workflows.find(function (w) { return w.transition_id == id; });
saveTransition(e); if (!wf) return;
}); document.getElementById('transition_id').value = wf.transition_id;
document.getElementById('from_status').value = wf.from_status;
document.getElementById('to_status').value = wf.to_status;
document.getElementById('requires_comment').checked = wf.requires_comment == 1;
document.getElementById('requires_admin').checked = wf.requires_admin == 1;
document.getElementById('wf_is_active').checked = wf.is_active == 1;
document.getElementById('wfModalTitle').textContent = 'Edit Transition';
lt.modal.open('workflowModal');
}
if (window.lt) lt.keys.initDefaults(); function deleteTransition(id) {
showConfirmModal('Delete Transition', 'Delete this status transition? This cannot be undone.', 'error', function () {
lt.api.delete('/api/manage_workflows.php?id=' + id)
.then(function (data) {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(function () { lt.toast.error('Failed to delete'); });
});
}
function saveTransition(e) { function saveTransition(e) {
e.preventDefault(); e.preventDefault();
const data = { var data = {
transition_id: document.getElementById('transition_id').value, transition_id: document.getElementById('transition_id').value,
from_status: document.getElementById('from_status').value, from_status: document.getElementById('from_status').value,
to_status: document.getElementById('to_status').value, to_status: document.getElementById('to_status').value,
requires_comment: document.getElementById('requires_comment').checked ? 1 : 0, requires_comment: document.getElementById('requires_comment').checked ? 1 : 0,
requires_admin: document.getElementById('requires_admin').checked ? 1 : 0, requires_admin: document.getElementById('requires_admin').checked ? 1 : 0,
is_active: document.getElementById('is_active').checked ? 1 : 0 is_active: document.getElementById('wf_is_active').checked ? 1 : 0,
}; };
var url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
var apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(function (result) {
if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
</script>
const url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : ''); <?php include __DIR__ . '/../../views/layout_footer.php'; ?>
const apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
lt.toast.error(result.error || 'Failed to save');
}
}).catch(err => lt.toast.error('Failed to save'));
}
function editTransition(id) {
const wf = workflows.find(w => w.transition_id == id);
if (!wf) return;
document.getElementById('transition_id').value = wf.transition_id;
document.getElementById('from_status').value = wf.from_status;
document.getElementById('to_status').value = wf.to_status;
document.getElementById('requires_comment').checked = wf.requires_comment == 1;
document.getElementById('requires_admin').checked = wf.requires_admin == 1;
document.getElementById('is_active').checked = wf.is_active == 1;
document.getElementById('modalTitle').textContent = 'Edit Transition';
lt.modal.open('workflowModal');
}
function deleteTransition(id) {
showConfirmModal('Delete Transition', 'Delete this status transition?', 'error', function() {
lt.api.delete('/api/manage_workflows.php?id=' + id)
.then(data => {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
});
}
</script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
<?php
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = '403 Forbidden';
$activeNav = '';
$pageStyles = [];
include __DIR__ . '/../views/layout_header.php';
?>
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header lt-text-danger">[ 403 ] ACCESS DENIED</div>
<div class="lt-section-body" style="text-align:center">
<p class="lt-text-muted lt-mb-md">You do not have permission to access this resource.</p>
<a href="/" class="lt-btn lt-btn-primary">&larr; Dashboard</a>
</div>
</div>
<?php include __DIR__ . '/../views/layout_footer.php'; ?>
+18
View File
@@ -0,0 +1,18 @@
<?php
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = '404 Not Found';
$activeNav = '';
$pageStyles = [];
include __DIR__ . '/../views/layout_header.php';
?>
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header lt-text-amber">[ 404 ] NOT FOUND</div>
<div class="lt-section-body" style="text-align:center">
<p class="lt-text-muted lt-mb-md">The page you requested does not exist.</p>
<a href="/" class="lt-btn lt-btn-primary">&larr; Dashboard</a>
</div>
</div>
<?php include __DIR__ . '/../views/layout_footer.php'; ?>
+190
View File
@@ -0,0 +1,190 @@
<?php
/**
* layout_footer.php — Shared bottom-of-page partial for all views.
*
* Expected variables available from the including view (set before require):
* string $nonce CSP nonce from SecurityHeadersMiddleware::getNonce()
* array|null $pageScripts Optional array of extra JS paths to load after base.js
* string|null $pageInlineScript Optional raw JS string to run after all scripts load
*
* Globals used:
* $GLOBALS['currentUser'] — user array (user_id, username, is_admin)
* $GLOBALS['config'] — app config array (TIMEZONE, TIMEZONE_ABBREV)
* CsrfMiddleware::getToken() — returns current CSRF token string
*/
// layout_footer.php — JS globals + runtime scripts are loaded here
?>
</main><!-- /#main-content / .lt-main -->
<!-- ================================================================
FOOTER — keyboard hint bar + version
================================================================ -->
<?php
// Context-sensitive keyboard hints based on active nav
$_ltf_nav = $activeNav ?? 'dashboard';
$_ltf_isTicket = str_starts_with($pageTitle ?? '', 'Ticket #');
?>
<footer class="lt-footer" role="contentinfo" aria-label="Keyboard shortcuts and app info">
<nav class="lt-footer-hints" aria-label="Keyboard shortcuts">
<?php if ($_ltf_isTicket): ?>
<a href="/" class="lt-footer-hint" title="Go to dashboard"><span class="lt-footer-key">[ ← ]</span> BACK</a>
<span class="lt-footer-sep">|</span>
<span class="lt-footer-hint" title="Press 14 to change status"><span class="lt-footer-key">[ 1-4 ]</span> STATUS</span>
<span class="lt-footer-sep">|</span>
<span class="lt-footer-hint" title="Press C to jump to comment box"><span class="lt-footer-key">[ C ]</span> COMMENT</span>
<span class="lt-footer-sep">|</span>
<button type="button" class="lt-footer-hint" data-action="open-settings" title="Open settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
<?php elseif (str_starts_with($_ltf_nav, 'admin')): ?>
<a href="/" class="lt-footer-hint" title="Go to dashboard"><span class="lt-footer-key">[ ~ ]</span> HOME</a>
<span class="lt-footer-sep">|</span>
<button type="button" class="lt-footer-hint" data-action="open-settings" title="Open settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
<?php else: ?>
<a href="/" class="lt-footer-hint" title="Go to dashboard (G then D)"><span class="lt-footer-key">[ ~ ]</span> HOME</a>
<span class="lt-footer-sep">|</span>
<span class="lt-footer-hint" title="Press / or Ctrl+K to search"><span class="lt-footer-key">[ / ]</span> SEARCH</span>
<span class="lt-footer-sep">|</span>
<a href="/ticket/create" class="lt-footer-hint" title="Create new ticket (N)"><span class="lt-footer-key">[ + ]</span> NEW</a>
<span class="lt-footer-sep">|</span>
<button type="button" class="lt-footer-hint" data-action="open-settings" title="Open settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
<?php endif ?>
<span class="lt-footer-sep">|</span>
<button type="button" class="lt-footer-hint" data-action="show-keyboard-help" title="Show keyboard shortcuts (?)"><span class="lt-footer-key">[ ? ]</span> HELP</button>
</nav>
<span aria-label="Application version"><?= htmlspecialchars($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', ENT_QUOTES, 'UTF-8') ?> &mdash; TDS v<?= htmlspecialchars($GLOBALS['config']['APP_VERSION'] ?? '1.2', ENT_QUOTES, 'UTF-8') ?></span>
</footer>
<!-- ================================================================
KEYBOARD SHORTCUTS HELP MODAL — opened by ? key or footer [?] hint
================================================================ -->
<div id="lt-keys-help" class="lt-modal-overlay" aria-hidden="true">
<div class="lt-modal" role="dialog" aria-modal="true" aria-labelledby="keys-help-title">
<div class="lt-modal-header">
<span class="lt-modal-title" id="keys-help-title">Keyboard Shortcuts</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<div class="lt-modal-body">
<table class="lt-data-table" style="width:100%">
<thead>
<tr><th scope="col">Shortcut</th><th scope="col">Action</th></tr>
</thead>
<tbody>
<tr><td>Ctrl / &#x2318; + K</td><td>Focus search box</td></tr>
<tr><td>Ctrl / &#x2318; + E</td><td>Toggle edit mode (ticket page)</td></tr>
<tr><td>Ctrl / &#x2318; + S</td><td>Save changes (ticket page)</td></tr>
<tr><td>j / &#x2193;</td><td>Select next row</td></tr>
<tr><td>k / &#x2191;</td><td>Select previous row</td></tr>
<tr><td>Enter</td><td>Open selected ticket</td></tr>
<tr><td>n</td><td>New ticket</td></tr>
<tr><td>1&ndash;4</td><td>Change ticket status (ticket page)</td></tr>
<tr><td>c</td><td>Jump to comment box (ticket page)</td></tr>
<tr><td>?</td><td>Show this help</td></tr>
<tr><td>ESC</td><td>Close modal / cancel</td></tr>
</tbody>
</table>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn" data-modal-close>Close</button>
</div>
</div>
</div>
<!-- ================================================================
COMMAND PALETTE — Ctrl+K opens when no search input focused
================================================================ -->
<div id="lt-cmd-overlay" class="lt-cmd-overlay" role="dialog" aria-modal="true" aria-label="Command palette" aria-hidden="true">
<div class="lt-cmd-palette" id="lt-cmd-palette">
<div class="lt-cmd-input-wrap">
<span class="lt-cmd-prompt">&gt;</span>
<input id="lt-cmd-input" class="lt-cmd-input" type="text"
placeholder="Search commands&hellip;" autocomplete="off"
spellcheck="false" aria-label="Search commands">
</div>
<div class="lt-cmd-results" id="lt-cmd-results">
<div class="lt-cmd-empty">Start typing to search&hellip;</div>
</div>
<div class="lt-cmd-footer">
<span><kbd>&#x2191;</kbd><kbd>&#x2193;</kbd> Navigate</span>
<span><kbd>Enter</kbd> Select</span>
<span><kbd>Esc</kbd> Close</span>
</div>
</div>
</div>
<!-- base.js + utils.js + globals already loaded in <head> via layout_header.php -->
<?php if (!empty($pageScripts)): ?>
<!-- PAGE-SPECIFIC SCRIPTS -->
<?php foreach ($pageScripts as $_ltf_script): ?>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="<?= htmlspecialchars($_ltf_script, ENT_QUOTES, 'UTF-8') ?>"></script>
<?php endforeach; ?>
<?php endif; ?>
<?php if (!empty($pageInlineScript)): ?>
<!-- PAGE INLINE SCRIPT -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
<?= $pageInlineScript ?>
</script>
<?php endif; ?>
<!-- LT INIT — boot animation + global UI init (base.js handles keys/nav automatically) -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
if (window.lt) {
lt.init({ bootName: <?= json_encode($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', JSON_HEX_TAG) ?> });
// Theme toggle button
var themeBtn = document.getElementById('lt-theme-btn');
if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); });
// Command palette — global navigation commands available on all pages
lt.cmdPalette.init([
{
group: 'Navigation',
items: [
{ icon: '~', label: 'Dashboard', kbd: 'G D', action: function() { window.location.href = '/'; } },
{ icon: '+', label: 'New Ticket', kbd: 'N', action: function() { window.location.href = '/ticket/create'; } },
]
},
{
group: 'Help',
items: [
{ icon: '?', label: 'Keyboard Shortcuts', kbd: '?', action: function() { lt.modal.open('lt-keys-help'); } },
]
},
]);
}
// Patch lt.api mutating methods to auto-rotate CSRF token when server returns a new one
if (window.lt && lt.api) {
['post', 'put', 'patch', 'delete'].forEach(function(method) {
if (typeof lt.api[method] !== 'function') return;
var _orig = lt.api[method];
lt.api[method] = function(url, body) {
return _orig.call(lt.api, url, body).then(function(data) {
if (data && data.csrf_token) window.CSRF_TOKEN = data.csrf_token;
return data;
});
};
});
}
// Footer hint bar actions (keyboard help + settings — work on all pages)
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.getAttribute('data-action');
if (action === 'show-keyboard-help') {
if (window.lt) lt.modal.open('lt-keys-help');
} else if (action === 'open-settings' || action === 'open-settings-modal') {
if (typeof openSettingsModal === 'function') {
openSettingsModal();
} else if (window.lt) {
lt.toast.info('Settings available on the Dashboard');
}
}
});
</script>
</body>
</html>
+187
View File
@@ -0,0 +1,187 @@
<?php
/**
* layout_header.php — Shared top-of-page partial for all views.
*
* Expected variables set by the including view before require:
* string $pageTitle Page title suffix (e.g. "Dashboard", "Ticket #42")
* string $activeNav Active nav key: 'dashboard', 'tickets', 'admin-*'
* array|null $pageStyles Optional extra CSS hrefs to load
* string $nonce CSP nonce from SecurityHeadersMiddleware::getNonce()
*
* Globals used:
* $GLOBALS['currentUser'] — user array (username, display_name, is_admin, groups)
* $GLOBALS['config'] — app config array
* CsrfMiddleware::getToken() — returns current CSRF token string
*/
$_lt_user = $GLOBALS['currentUser'] ?? [];
$_lt_isAdmin = !empty($_lt_user['is_admin']);
$_lt_navActive = $activeNav ?? 'dashboard';
$_lt_appName = $GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS';
$_lt_subtitle = $GLOBALS['config']['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure';
$_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#030508">
<title><?= htmlspecialchars($pageTitle ?? 'Dashboard', ENT_QUOTES, 'UTF-8') ?> &mdash; <?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></title>
<meta name="robots" content="noindex, nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/base.css?v=<?= $_lt_assetVer ?>">
<?php if (!empty($pageStyles)): ?>
<?php foreach ($pageStyles as $_lt_css): ?>
<link rel="stylesheet" href="<?= htmlspecialchars($_lt_css, ENT_QUOTES, 'UTF-8') ?>">
<?php endforeach; ?>
<?php endif; ?>
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
<!-- Base JS loaded in head so lt.* is available for inline view scripts -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/base.js?v=<?= $_lt_assetVer ?>"></script>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/utils.js?v=<?= $_lt_assetVer ?>"></script>
<!-- Inline JS globals (CSRF, timezone, user) available immediately -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
window.CSRF_TOKEN = <?= json_encode(CsrfMiddleware::getToken(), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
window.APP_TIMEZONE = <?= json_encode($GLOBALS['config']['TIMEZONE'] ?? 'UTC', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
window.APP_TIMEZONE_ABBREV = <?= json_encode($GLOBALS['config']['TIMEZONE_ABBREV'] ?? 'UTC', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
window.APP_TIMEZONE_OFFSET = <?= (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0) ?>;
window.CURRENT_USER = <?= json_encode([
'id' => (int)($GLOBALS['currentUser']['user_id'] ?? 0),
'username'=> $GLOBALS['currentUser']['username'] ?? '',
'isAdmin' => !empty($GLOBALS['currentUser']['is_admin']),
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
</script>
</head>
<body>
<!-- Scanlines overlay — dedicated div avoids conflict with body::before/::after pseudo-elements -->
<div class="lt-scanlines" aria-hidden="true" style="position:fixed;inset:0;pointer-events:none;z-index:9998"></div>
<!-- SKIP LINK -->
<a class="lt-skip-link" href="#main-content">Skip to main content</a>
<!-- BOOT OVERLAY — controlled by lt.boot() in base.js; shown once per session -->
<div id="lt-boot" class="lt-boot-overlay" data-app-name="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?>" style="display:none" aria-hidden="true">
<pre id="lt-boot-text" class="lt-boot-text"></pre>
</div>
<!-- MOBILE NAV DRAWER — matches web_template structure exactly -->
<div id="lt-nav-drawer" class="lt-nav-drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Navigation menu">
<div class="lt-nav-drawer-header">
<span class="lt-brand-title"><?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></span>
<button type="button" class="lt-nav-drawer-close" id="lt-nav-drawer-close" aria-label="Close navigation">&#x2715;</button>
</div>
<nav class="lt-nav-drawer-links" aria-label="Mobile navigation">
<a href="/"
class="lt-nav-drawer-link<?= $_lt_navActive === 'dashboard' ? ' active' : '' ?>"
<?= $_lt_navActive === 'dashboard' ? 'aria-current="page"' : '' ?>>Dashboard</a>
<?php if ($_lt_isAdmin): ?>
<div class="lt-nav-drawer-section">Admin</div>
<a href="/admin/templates" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-templates' ? ' active' : '' ?>">Templates</a>
<a href="/admin/workflow" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-workflow' ? ' active' : '' ?>">Workflow</a>
<a href="/admin/recurring-tickets" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-recurring' ? ' active' : '' ?>">Recurring</a>
<a href="/admin/custom-fields" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-custom-fields' ? ' active' : '' ?>">Custom Fields</a>
<a href="/admin/user-activity" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-user-activity' ? ' active' : '' ?>">User Activity</a>
<a href="/admin/audit-log" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-audit-log' ? ' active' : '' ?>">Audit Log</a>
<a href="/admin/api-keys" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-api-keys' ? ' active' : '' ?>">API Keys</a>
<?php endif; ?>
</nav>
</div><!-- /.lt-nav-drawer -->
<!-- Overlay: outside drawer, full-screen; JS toggles .open class -->
<div id="lt-nav-overlay" class="lt-nav-drawer-overlay"></div>
<!-- PRIMARY HEADER -->
<header class="lt-header" role="banner">
<div class="lt-header-left">
<!-- Hamburger — opens mobile nav drawer -->
<button type="button"
class="lt-menu-btn"
id="lt-menu-btn"
data-action="open-nav-drawer"
aria-label="Open navigation menu"
aria-expanded="false"
aria-controls="lt-nav-drawer">
<span class="lt-menu-btn-bar"></span>
<span class="lt-menu-btn-bar"></span>
<span class="lt-menu-btn-bar"></span>
</button>
<!-- Brand -->
<div class="lt-brand">
<a href="/"
class="lt-brand-title lt-glitch"
data-text="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?>"
style="text-decoration:none"
aria-label="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?> home"><?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></a>
<span class="lt-brand-subtitle"><?= htmlspecialchars($_lt_subtitle, ENT_QUOTES, 'UTF-8') ?></span>
</div>
<!-- Desktop navigation -->
<nav class="lt-nav" aria-label="Main navigation">
<a href="/"
class="lt-nav-link<?= $_lt_navActive === 'dashboard' ? ' active' : '' ?>"
<?= $_lt_navActive === 'dashboard' ? 'aria-current="page"' : '' ?>>
Dashboard
</a>
<?php if ($_lt_isAdmin): ?>
<div class="lt-nav-dropdown" data-action="toggle-nav-dropdown">
<a href="#"
class="lt-nav-link<?= str_starts_with($_lt_navActive, 'admin') ? ' active' : '' ?>"
role="button"
aria-haspopup="true"
aria-expanded="false"
aria-controls="lt-admin-dropdown-menu">
Admin &#x25BE;
</a>
<ul class="lt-nav-dropdown-menu"
id="lt-admin-dropdown-menu"
role="menu"
aria-label="Admin menu">
<li role="none"><a href="/admin/templates" role="menuitem" class="<?= $_lt_navActive === 'admin-templates' ? 'active' : '' ?>">Templates</a></li>
<li role="none"><a href="/admin/workflow" role="menuitem" class="<?= $_lt_navActive === 'admin-workflow' ? 'active' : '' ?>">Workflow</a></li>
<li role="none"><a href="/admin/recurring-tickets" role="menuitem" class="<?= $_lt_navActive === 'admin-recurring' ? 'active' : '' ?>">Recurring</a></li>
<li role="none"><a href="/admin/custom-fields" role="menuitem" class="<?= $_lt_navActive === 'admin-custom-fields' ? 'active' : '' ?>">Custom Fields</a></li>
<li role="none"><a href="/admin/user-activity" role="menuitem" class="<?= $_lt_navActive === 'admin-user-activity' ? 'active' : '' ?>">User Activity</a></li>
<li role="none"><a href="/admin/audit-log" role="menuitem" class="<?= $_lt_navActive === 'admin-audit-log' ? 'active' : '' ?>">Audit Log</a></li>
<li role="none"><a href="/admin/api-keys" role="menuitem" class="<?= $_lt_navActive === 'admin-api-keys' ? 'active' : '' ?>">API Keys</a></li>
</ul>
</div>
<?php endif; ?>
</nav><!-- /.lt-nav -->
</div><!-- /.lt-header-left -->
<div class="lt-header-right">
<?php if (!empty($_lt_user)): ?>
<?php
$_lt_displayName = $_lt_user['display_name'] ?? $_lt_user['username'] ?? '';
$_lt_words = array_filter(explode(' ', $_lt_displayName));
$_lt_initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($_lt_words, 0, 2))));
$_lt_userId = (int)($_lt_user['user_id'] ?? 0);
$_lt_avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
$_lt_avatarColor = $_lt_avatarColors[abs(crc32($_lt_displayName)) % count($_lt_avatarColors)];
?>
<div class="lt-avatar lt-avatar--sm <?= $_lt_avatarColor ?>" aria-hidden="true" title="<?= htmlspecialchars($_lt_displayName, ENT_QUOTES, 'UTF-8') ?>">
<?php if ($_lt_userId > 0): ?>
<img src="/api/user_avatar.php?user_id=<?= $_lt_userId ?>"
alt=""
class="lt-avatar-img"
onerror="this.style.display='none'">
<?php endif ?>
<span class="lt-avatar-initials"><?= htmlspecialchars($_lt_initials) ?></span>
</div>
<span class="lt-header-user"><?= htmlspecialchars($_lt_displayName, ENT_QUOTES, 'UTF-8') ?></span>
<?php if ($_lt_isAdmin): ?>
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
<?php endif; ?>
<?php endif; ?>
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
aria-label="Switch to light mode" title="Switch to light mode">&#x2600;</button>
</div><!-- /.lt-header-right -->
</header><!-- /.lt-header -->
<main class="lt-main lt-container" id="main-content">