From eb9d0ace8a45a07a169f5d531e07f27c916606c2 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Wed, 6 May 2026 09:32:44 +1000 Subject: [PATCH] feat: add Disco job link, GUI config editor, change history, version display --- WebHandler/ServiceTrackerWebHandler.cs | 604 ++++++++++++++----------- 1 file changed, 340 insertions(+), 264 deletions(-) diff --git a/WebHandler/ServiceTrackerWebHandler.cs b/WebHandler/ServiceTrackerWebHandler.cs index 080824b..a07e110 100644 --- a/WebHandler/ServiceTrackerWebHandler.cs +++ b/WebHandler/ServiceTrackerWebHandler.cs @@ -1,7 +1,9 @@ using Disco.Plugins.ServiceTracker.Models; using Disco.Plugins.ServiceTracker.Services; using Disco.Services.Plugins; +using Newtonsoft.Json; using System; +using System.Collections.Generic; using System.Linq; using System.Text; using System.Web; @@ -23,20 +25,14 @@ namespace Disco.Plugins.ServiceTracker.WebHandler var action = ActionName != null ? ActionName.ToLower() : ""; switch (action) { - case "": - case "index": - case "dashboard": - return Dashboard(); - case "update": - return UpdateTicket(); - case "addnote": - return AddNote(); - case "detail": - return TicketDetail(); - case "export": - return ExportCsv(); - default: - return new HttpNotFoundResult(); + case "": case "index": case "dashboard": return Dashboard(); + case "update": return UpdateTicket(); + case "addnote": return AddNote(); + case "detail": return TicketDetail(); + case "export": return ExportCsv(); + case "config": return ConfigEditor(); + case "saveconfig": return SaveConfig(); + default: return new HttpNotFoundResult(); } } @@ -55,59 +51,48 @@ namespace Disco.Plugins.ServiceTracker.WebHandler private ActionResult UpdateTicket() { - if (HostController.Request.HttpMethod != "POST") - return new HttpStatusCodeResult(405); + if (HostController.Request.HttpMethod != "POST") return new HttpStatusCodeResult(405); var dataStore = GetDataStore(); var service = new ServiceTrackerService(Database, dataStore); int jobId; - if (!int.TryParse(HostController.Request.Form["jobId"], out jobId)) - return new HttpStatusCodeResult(400); - var priorityId = HostController.Request.Form["priority"]; - var locationId = HostController.Request.Form["location"]; - var techId = HostController.Request.Form["tech"]; - var status = HostController.Request.Form["status"]; - var summary = HostController.Request.Form["summary"]; + if (!int.TryParse(HostController.Request.Form["jobId"], out jobId)) return new HttpStatusCodeResult(400); + var currentUser = GetCurrentUser(); DateTime? eta = null; DateTime etaParsed; - if (DateTime.TryParse(HostController.Request.Form["eta"], out etaParsed)) - eta = etaParsed; - var currentUser = GetCurrentUser(); - service.UpdateTicket(jobId, priorityId, locationId, techId, eta, status, summary, currentUser); - return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Dashboard"); + if (DateTime.TryParse(HostController.Request.Form["eta"], out etaParsed)) eta = etaParsed; + service.UpdateTicket(jobId, + HostController.Request.Form["priority"], HostController.Request.Form["location"], + HostController.Request.Form["tech"], eta, + HostController.Request.Form["status"], HostController.Request.Form["summary"], currentUser); + return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Detail?id=" + jobId); } private ActionResult AddNote() { - if (HostController.Request.HttpMethod != "POST") - return new HttpStatusCodeResult(405); + if (HostController.Request.HttpMethod != "POST") return new HttpStatusCodeResult(405); var dataStore = GetDataStore(); var service = new ServiceTrackerService(Database, dataStore); int jobId; - if (!int.TryParse(HostController.Request.Form["jobId"], out jobId)) - return new HttpStatusCodeResult(400); - var content = HostController.Request.Form["note"]; - var noteType = HostController.Request.Form["noteType"] ?? "general"; + if (!int.TryParse(HostController.Request.Form["jobId"], out jobId)) return new HttpStatusCodeResult(400); var currentUser = GetCurrentUser(); - service.AddNote(jobId, currentUser, currentUser, content, noteType); + service.AddNote(jobId, currentUser, currentUser, + HostController.Request.Form["note"], HostController.Request.Form["noteType"] ?? "general"); return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Detail?id=" + jobId); } private ActionResult TicketDetail() { int jobId; - if (!int.TryParse(HostController.Request.QueryString["id"], out jobId)) - return new HttpStatusCodeResult(400); + if (!int.TryParse(HostController.Request.QueryString["id"], out jobId)) return new HttpStatusCodeResult(400); var dataStore = GetDataStore(); var service = new ServiceTrackerService(Database, dataStore); var config = dataStore.LoadConfig(); var job = Database.Jobs .Include("Device").Include("Device.DeviceModel") .Include("User").Include("OpenedTechUser") - .Include("JobType").Include("JobSubTypes") - .Include("JobLogs") + .Include("JobType").Include("JobSubTypes").Include("JobLogs") .FirstOrDefault(j => j.Id == jobId); - if (job == null) - return new HttpNotFoundResult(); + if (job == null) return new HttpNotFoundResult(); var ticket = service.GetTicketDetail(jobId); return HtmlResult(BuildDetailPage(job, ticket, config)); } @@ -123,17 +108,74 @@ namespace Disco.Plugins.ServiceTracker.WebHandler { var etaStr = t.EstimatedCompletion.HasValue ? t.EstimatedCompletion.Value.ToString("yyyy-MM-dd") : ""; var slaStr = t.SlaDeadline.HasValue ? t.SlaDeadline.Value.ToString("yyyy-MM-dd HH:mm") : ""; - sb.AppendLine(string.Join(",", - t.JobId, Csv(t.DeviceSerialNumber), Csv(t.UserDisplayName), - Csv(t.PriorityName), Csv(t.LocationName), Csv(t.StatusOverride), - Csv(t.AssignedTechName), t.OpenedDate.ToString("yyyy-MM-dd"), - etaStr, slaStr, t.IsSlaBreached, t.AgeDays, Csv(t.Summary), t.NoteCount)); + sb.AppendLine(string.Join(",", t.JobId, Csv(t.DeviceSerialNumber), Csv(t.UserDisplayName), + Csv(t.PriorityName), Csv(t.LocationName), Csv(t.StatusOverride), Csv(t.AssignedTechName), + t.OpenedDate.ToString("yyyy-MM-dd"), etaStr, slaStr, t.IsSlaBreached, t.AgeDays, Csv(t.Summary), t.NoteCount)); } - var fileName = "ServiceTracker_Export_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv"; - HostController.Response.Headers.Add("Content-Disposition", "attachment; filename=\"" + fileName + "\""); + HostController.Response.Headers.Add("Content-Disposition", "attachment; filename=\"ServiceTracker_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv\""); return new ContentResult { Content = sb.ToString(), ContentType = "text/csv", ContentEncoding = Encoding.UTF8 }; } + private ActionResult ConfigEditor() + { + var dataStore = GetDataStore(); + var config = dataStore.LoadConfig(); + var saved = HostController.Request.QueryString["saved"] == "1"; + return HtmlResult(BuildConfigPage(config, saved)); + } + + private ActionResult SaveConfig() + { + if (HostController.Request.HttpMethod != "POST") return new HttpStatusCodeResult(405); + var dataStore = GetDataStore(); + var config = dataStore.LoadConfig(); + + // General settings + int refresh; + if (int.TryParse(HostController.Request.Form["refreshSeconds"], out refresh) && refresh >= 10) + config.DashboardRefreshSeconds = refresh; + config.AutoCreateTicketsForNewJobs = HostController.Request.Form["autoCreate"] == "on"; + var dp = HostController.Request.Form["defaultPriority"]; + if (!string.IsNullOrEmpty(dp)) config.DefaultPriorityId = dp; + var dl = HostController.Request.Form["defaultLocation"]; + if (!string.IsNullOrEmpty(dl)) config.DefaultLocationId = dl; + + // Status options + var statusRaw = HostController.Request.Form["statusOptions"]; + if (statusRaw != null) + { + config.StatusOptions = statusRaw.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()).Where(s => s.Length > 0).ToList(); + } + + // Priorities JSON + var priJson = HostController.Request.Form["prioritiesJson"]; + if (!string.IsNullOrEmpty(priJson)) + { + try + { + var list = JsonConvert.DeserializeObject>(priJson); + if (list != null && list.Count > 0) config.Priorities = list; + } + catch { } + } + + // Locations JSON + var locJson = HostController.Request.Form["locationsJson"]; + if (!string.IsNullOrEmpty(locJson)) + { + try + { + var list = JsonConvert.DeserializeObject>(locJson); + if (list != null && list.Count > 0) config.Locations = list; + } + catch { } + } + + dataStore.SaveConfig(config); + return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Config?saved=1"); + } + // --- Helpers --- private string GetCurrentUser() @@ -142,66 +184,32 @@ namespace Disco.Plugins.ServiceTracker.WebHandler return HostController.HttpContext.User.Identity.Name ?? "system"; return "system"; } - - private ActionResult HtmlResult(string html) - { - return new ContentResult { Content = html, ContentType = "text/html", ContentEncoding = Encoding.UTF8 }; - } + private ActionResult HtmlResult(string html) { return new ContentResult { Content = html, ContentType = "text/html", ContentEncoding = Encoding.UTF8 }; } private string Csv(string v) { return "\"" + (v ?? "").Replace("\"", "\"\"") + "\""; } private string H(string v) { return string.IsNullOrEmpty(v) ? "" : HttpUtility.HtmlEncode(v); } + private string SafeDeviceDomainId(Disco.Models.Repository.Job j) { return j.Device != null ? j.Device.DeviceDomainId : null; } + private string SafeDeviceModelDesc(Disco.Models.Repository.Job j) { return (j.Device != null && j.Device.DeviceModel != null) ? j.Device.DeviceModel.Description : null; } + private string SafeUserDisplay(Disco.Models.Repository.Job j) { return j.User != null ? j.User.DisplayName : j.UserId; } + private string SafeJobTypeDesc(Disco.Models.Repository.Job j) { return j.JobType != null ? j.JobType.Description : j.JobTypeId; } + private string SafeTechDisplay(Disco.Models.Repository.Job j) { return j.OpenedTechUser != null ? j.OpenedTechUser.DisplayName : j.OpenedTechUserId; } - // --- Safe accessors for job navigation properties (C#5 compatible) --- - private string SafeDeviceDomainId(Disco.Models.Repository.Job job) - { - return job.Device != null ? job.Device.DeviceDomainId : null; - } - private string SafeDeviceModelDesc(Disco.Models.Repository.Job job) - { - return (job.Device != null && job.Device.DeviceModel != null) ? job.Device.DeviceModel.Description : null; - } - private string SafeUserDisplay(Disco.Models.Repository.Job job) - { - return job.User != null ? job.User.DisplayName : job.UserId; - } - private string SafeJobTypeDesc(Disco.Models.Repository.Job job) - { - return job.JobType != null ? job.JobType.Description : job.JobTypeId; - } - private string SafeTechDisplay(Disco.Models.Repository.Job job) - { - return job.OpenedTechUser != null ? job.OpenedTechUser.DisplayName : job.OpenedTechUserId; - } - private string SafeTicketStr(ServiceTicket ticket, string field) - { - if (ticket == null) return null; - switch (field) - { - case "PriorityId": return ticket.PriorityId; - case "LocationId": return ticket.LocationId; - case "AssignedTechId": return ticket.AssignedTechId; - case "StatusOverride": return ticket.StatusOverride; - case "Summary": return ticket.Summary; - default: return null; - } - } - - // --- HTML Builders --- + // ===================== DASHBOARD PAGE ===================== private string BuildDashboardPage(DashboardViewModel model) { - var pluginUrl = "/Plugin/Disco.Plugins.ServiceTracker"; + var u = "/Plugin/Disco.Plugins.ServiceTracker"; var sb = new StringBuilder(); - sb.Append(""); - sb.Append("Service Tracker Dashboard"); - sb.Append(""); + sb.Append("Service Tracker"); // Header sb.Append("
"); sb.Append("

🛠 Service Tracker

"); sb.Append("Open Jobs: " + model.Stats.TotalOpen + ""); sb.Append("
"); - sb.Append("⬇ Export CSV"); - sb.Append("↻ Refresh"); + sb.Append("⚙ Config"); + sb.Append("⬇ Export"); + sb.Append("↻ Refresh"); sb.Append("
"); // SLA Alerts @@ -210,66 +218,55 @@ namespace Disco.Plugins.ServiceTracker.WebHandler if (model.Stats.SlaWarning > 0) sb.Append("
" + model.Stats.SlaWarning + " job(s) approaching SLA deadline
"); - // Stats Bar + // Stats sb.Append("
"); foreach (var p in model.Config.Priorities) { - int count; model.Stats.ByPriority.TryGetValue(p.Id, out count); - sb.Append(""); - sb.Append("
" + count + "
"); - sb.Append("
" + H(p.Name) + "
"); + int c; model.Stats.ByPriority.TryGetValue(p.Id, out c); + sb.Append("
" + c + "
" + H(p.Name) + "
"); } sb.Append("
" + model.Stats.SlaBreached + "
SLA Breached
"); - sb.Append("
" + model.Stats.AvgAgeDays.ToString("0.0") + "
Avg Age (days)
"); - sb.Append("
"); + sb.Append("
" + model.Stats.AvgAgeDays.ToString("0.0") + "
Avg Age (days)
"); - // Location Summary + // Location chips sb.Append("
"); foreach (var loc in model.Config.Locations) { - int count; model.Stats.ByLocation.TryGetValue(loc.Id, out count); - if (count > 0) - sb.Append("" + loc.Icon + " " + H(loc.Name) + " " + count + ""); + int c; model.Stats.ByLocation.TryGetValue(loc.Id, out c); + if (c > 0) sb.Append("" + loc.Icon + " " + H(loc.Name) + " " + c + ""); } sb.Append("
"); - // Sort Controls + // Sort controls sb.Append("
Sort by:"); - string[] sortOptions = { "due|Due Date", "priority|Priority", "age|Age", "sla|SLA Status", "modified|Last Updated" }; - foreach (var opt in sortOptions) + foreach (var opt in new[] { "due|Due Date", "priority|Priority", "age|Age", "sla|SLA Status", "modified|Last Updated" }) { - var parts = opt.Split('|'); - var active = parts[0] == model.SortBy ? " active" : ""; - sb.Append("" + parts[1] + ""); + var p = opt.Split('|'); + sb.Append("" + p[1] + ""); } if (!string.IsNullOrEmpty(model.CurrentFilter)) - sb.Append("✖ Clear Filter"); + sb.Append("✖ Clear Filter"); sb.Append("
"); - // Priority Legend + // Legend sb.Append("
Priority:"); foreach (var p in model.Config.Priorities) { sb.Append("" + H(p.Name)); - if (p.SlaHours > 0) sb.Append(" (" + p.SlaHours + "h SLA)"); + if (p.SlaHours > 0) sb.Append(" (" + p.SlaHours + "h)"); sb.Append(""); } sb.Append("
"); - // Tile Grid + // Tiles sb.Append("
"); if (model.Tiles.Count == 0) - { - sb.Append("
No open jobs found
"); - } + sb.Append("
No open jobs
"); else - { - foreach (var tile in model.Tiles) - sb.Append(BuildTileHtml(tile, pluginUrl)); - } + foreach (var tile in model.Tiles) sb.Append(BuildTileHtml(tile, u)); sb.Append("
"); - // Tech Workload + // Tech workload if (model.Stats.ByTech.Count > 0) { sb.Append("

Tech Workload

"); @@ -278,180 +275,248 @@ namespace Disco.Plugins.ServiceTracker.WebHandler sb.Append("
"); } - // Auto-refresh bar - var refreshSeconds = model.Config.DashboardRefreshSeconds; - sb.Append("
"); - sb.Append("Auto-refresh in " + refreshSeconds + "s"); + // Refresh bar + var rs = model.Config.DashboardRefreshSeconds; + sb.Append("
Auto-refresh in " + rs + "s"); sb.Append("
"); - sb.Append(""); - sb.Append("
"); - - sb.Append(""); - - // Auto-refresh JavaScript - sb.Append(""); + sb.Append("
"); + sb.Append(""); + sb.Append(""); sb.Append(""); return sb.ToString(); } - private string BuildTileHtml(DashboardTile tile, string pluginUrl) + private string BuildTileHtml(DashboardTile t, string u) { var sb = new StringBuilder(); - string borderClass = tile.IsSlaBreached ? "tile-breached" : tile.IsSlaWarning ? "tile-warning" : ""; - sb.Append("
"); - sb.Append("
"); - sb.Append("
#" + tile.JobId + "
" + tile.AgeBadge + "
"); - if (tile.IsSlaBreached) - sb.Append("
⚠ SLA BREACHED
"); - else if (tile.IsSlaWarning) - sb.Append("
⏰ SLA Warning
"); - sb.Append("
" + H(tile.DeviceComputerName ?? tile.DeviceSerialNumber) + "
"); - if (tile.DeviceModelDescription != null) - sb.Append("
" + H(tile.DeviceModelDescription) + "
"); + var bc = t.IsSlaBreached ? "tile-breached" : t.IsSlaWarning ? "tile-warning" : ""; + sb.Append("
"); + sb.Append("
"); + sb.Append("
#" + t.JobId + "
" + t.AgeBadge + "
"); + if (t.IsSlaBreached) sb.Append("
⚠ SLA BREACHED
"); + else if (t.IsSlaWarning) sb.Append("
⏰ SLA Warning
"); + sb.Append("
" + H(t.DeviceComputerName ?? t.DeviceSerialNumber) + "
"); + if (t.DeviceModelDescription != null) sb.Append("
" + H(t.DeviceModelDescription) + "
"); sb.Append("
"); - sb.Append("
👤" + H(tile.UserDisplayName ?? "—") + "
"); - sb.Append("
" + tile.LocationIcon + " " + H(tile.LocationName) + "
"); - sb.Append("
📋" + H(tile.StatusOverride ?? tile.DiscoStatus) + "
"); - if (!string.IsNullOrEmpty(tile.AssignedTechName)) - sb.Append("
🔧" + H(tile.AssignedTechName) + "
"); - sb.Append("
📅ETA: " + H(tile.EtaDisplay) + "
"); - if (!string.IsNullOrEmpty(tile.Summary)) - sb.Append("
" + H(tile.Summary) + "
"); - if (!string.IsNullOrEmpty(tile.LatestNote)) + sb.Append("
👤" + H(t.UserDisplayName ?? "\u2014") + "
"); + sb.Append("
" + t.LocationIcon + " " + H(t.LocationName) + "
"); + sb.Append("
📋" + H(t.StatusOverride ?? t.DiscoStatus) + "
"); + if (!string.IsNullOrEmpty(t.AssignedTechName)) + sb.Append("
🔧" + H(t.AssignedTechName) + "
"); + sb.Append("
📅ETA: " + H(t.EtaDisplay) + "
"); + if (!string.IsNullOrEmpty(t.Summary)) sb.Append("
" + H(t.Summary) + "
"); + if (!string.IsNullOrEmpty(t.LatestNote)) { - sb.Append("
💬 " + H(tile.LatestNote)); - if (tile.NoteCount > 1) sb.Append(" (+" + (tile.NoteCount - 1) + " more)"); + sb.Append("
💬 " + H(t.LatestNote)); + if (t.NoteCount > 1) sb.Append(" (+" + (t.NoteCount - 1) + " more)"); sb.Append("
"); } - sb.Append(""); - sb.Append("
"); + sb.Append("
"); return sb.ToString(); } + // ===================== DETAIL PAGE ===================== + private string BuildDetailPage(Disco.Models.Repository.Job job, ServiceTicket ticket, ServiceTrackerConfig config) { - var pluginUrl = "/Plugin/Disco.Plugins.ServiceTracker"; + var u = "/Plugin/Disco.Plugins.ServiceTracker"; var sb = new StringBuilder(); - sb.Append(""); - sb.Append("Job #" + job.Id + " - Service Tracker"); + sb.Append("Job #" + job.Id + ""); sb.Append(""); + + // Header with Disco link sb.Append("
"); - sb.Append("← Dashboard"); - sb.Append("

Job #" + job.Id + "

"); + sb.Append("← Dashboard"); + sb.Append("

Job #" + job.Id + "

"); + sb.Append("
"); + sb.Append("📂 Open in Disco"); + sb.Append("
"); + sb.Append("
"); - // Left column - Job info - sb.Append("

Job Details

"); + // Left column + sb.Append("
"); + + // Job info card + sb.Append("

Job Details

"); var domainId = SafeDeviceDomainId(job); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); - if (job.ExpectedClosedDate.HasValue) - sb.Append(""); - if (job.DeviceHeld.HasValue) - sb.Append(""); + if (job.ExpectedClosedDate.HasValue) sb.Append(""); + if (job.DeviceHeld.HasValue) sb.Append(""); sb.Append("
Device" + H(job.DeviceSerialNumber) + (domainId != null ? " (" + H(domainId) + ")" : "") + "
Model" + H(SafeDeviceModelDesc(job)) + "
User" + H(SafeUserDisplay(job)) + "
Type" + H(SafeJobTypeDesc(job)) + "
Opened" + job.OpenedDate.ToString("dd MMM yyyy HH:mm") + " by " + H(SafeTechDisplay(job)) + "
Expected Close" + job.ExpectedClosedDate.Value.ToString("dd MMM yyyy") + "
Device Held" + job.DeviceHeld.Value.ToString("dd MMM yyyy") + (job.DeviceHeldLocation != null ? " — " + H(job.DeviceHeldLocation) : "") + "
Expected Close" + job.ExpectedClosedDate.Value.ToString("dd MMM yyyy") + "
Device Held" + job.DeviceHeld.Value.ToString("dd MMM yyyy") + (job.DeviceHeldLocation != null ? " \u2014 " + H(job.DeviceHeldLocation) : "") + "
"); // Edit form - var ticketPriority = SafeTicketStr(ticket, "PriorityId") ?? config.DefaultPriorityId; - var ticketLocation = SafeTicketStr(ticket, "LocationId") ?? config.DefaultLocationId; - var ticketStatus = SafeTicketStr(ticket, "StatusOverride"); - var ticketTech = SafeTicketStr(ticket, "AssignedTechId") ?? ""; - var ticketSummary = SafeTicketStr(ticket, "Summary") ?? ""; - var ticketEta = (ticket != null && ticket.EstimatedCompletion.HasValue) ? ticket.EstimatedCompletion.Value.ToString("yyyy-MM-dd") : ""; + var tPri = (ticket != null ? ticket.PriorityId : null) ?? config.DefaultPriorityId; + var tLoc = (ticket != null ? ticket.LocationId : null) ?? config.DefaultLocationId; + var tSts = ticket != null ? ticket.StatusOverride : null; + var tTech = ticket != null ? ticket.AssignedTechId : ""; + var tSum = ticket != null ? ticket.Summary : ""; + var tEta = (ticket != null && ticket.EstimatedCompletion.HasValue) ? ticket.EstimatedCompletion.Value.ToString("yyyy-MM-dd") : ""; - sb.Append("

Service Tracker Settings

"); - sb.Append("
"); + sb.Append("

Service Tracker Settings

"); sb.Append(""); - sb.Append("
"); - sb.Append("
"); - - sb.Append("
"); + foreach (var s in config.StatusOptions) sb.Append(""); sb.Append("
"); - - sb.Append("
"); - sb.Append("
"); - sb.Append("
"); + sb.Append("
"); + sb.Append("
"); + sb.Append("
"); sb.Append(""); sb.Append("
"); - // Right column - Notes - sb.Append("

Activity Log

"); - sb.Append("
"); + // Right column - Activity + Change Log + sb.Append("
"); + + // Notes + sb.Append("

Activity Log

"); + sb.Append(""); sb.Append(""); sb.Append(""); sb.Append("
"); - if (ticket != null && ticket.Notes != null && ticket.Notes.Count > 0) { sb.Append("
"); foreach (var note in ticket.Notes.OrderByDescending(n => n.Timestamp)) { string tc = "#337AB7"; - switch (note.NoteType) - { - case "escalation": tc = "#DC3545"; break; - case "resolution": tc = "#28A745"; break; - case "update": tc = "#FFC107"; break; - } - var authorDisplay = note.AuthorName != null ? note.AuthorName : note.AuthorId; + switch (note.NoteType) { case "escalation": tc = "#DC3545"; break; case "resolution": tc = "#28A745"; break; case "update": tc = "#FFC107"; break; } + var author = note.AuthorName != null ? note.AuthorName : note.AuthorId; sb.Append("
"); - sb.Append("
" + H(authorDisplay) + ""); + sb.Append("
" + H(author) + ""); sb.Append("" + H(note.NoteType) + ""); sb.Append("" + note.Timestamp.ToString("dd MMM HH:mm") + "
"); sb.Append("
" + H(note.Content) + "
"); } sb.Append("
"); } - else + else sb.Append("

No notes yet.

"); + sb.Append("
"); + + // Change Log + if (ticket != null && ticket.ChangeLog != null && ticket.ChangeLog.Count > 0) { - sb.Append("

No notes yet.

"); + sb.Append("

📝 Change History

"); + sb.Append(""); + foreach (var ch in ticket.ChangeLog.OrderByDescending(c => c.Timestamp)) + { + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + } + sb.Append("
WhenWhoFieldFromTo
" + ch.Timestamp.ToString("dd MMM HH:mm") + "" + H(ch.UserId) + "" + H(ch.Field) + "" + H(ch.OldValue ?? "\u2014") + "" + H(ch.NewValue ?? "\u2014") + "
"); } - sb.Append("
"); + + sb.Append("
"); // detail-right, detail-grid + sb.Append(""); sb.Append(""); return sb.ToString(); } + // ===================== CONFIG EDITOR PAGE ===================== + + private string BuildConfigPage(ServiceTrackerConfig config, bool saved) + { + var u = "/Plugin/Disco.Plugins.ServiceTracker"; + var sb = new StringBuilder(); + sb.Append("Service Tracker Config"); + sb.Append(""); + + sb.Append("
"); + sb.Append("← Dashboard"); + sb.Append("

⚙ Configuration

"); + sb.Append("v" + ServiceTrackerService.PluginVersion + ""); + sb.Append("
"); + + if (saved) sb.Append("
✅ Configuration saved successfully!
"); + + sb.Append("
"); + sb.Append("
"); + + // General Settings + sb.Append("

General Settings

"); + sb.Append("
"); + sb.Append("
"); + sb.Append("
"); + sb.Append("
"); + sb.Append("
"); + + // Priority Levels + sb.Append("

Priority Levels

"); + sb.Append("

Edit the JSON below to customise priority levels. Each entry needs: id, Name, Color (hex), SortOrder, SlaHours, Description.

"); + sb.Append(""); + sb.Append("

Current Priorities

"); + foreach (var p in config.Priorities.OrderBy(x => x.SortOrder)) + sb.Append("" + H(p.Name) + " (" + p.SlaHours + "h)"); + sb.Append("
"); + + // Locations + sb.Append("

Device Locations

"); + sb.Append("

Edit the JSON below to customise locations. Each entry needs: Id, Name, Icon (emoji/HTML entity), Color (hex).

"); + sb.Append(""); + sb.Append("

Current Locations

"); + foreach (var l in config.Locations) + sb.Append("" + l.Icon + " " + H(l.Name) + ""); + sb.Append("
"); + + // Status Options + sb.Append("

Status Options

"); + sb.Append("

One status per line. These appear in the status dropdown on the ticket detail page.

"); + sb.Append("
"); + + sb.Append("
"); // config-grid + + sb.Append("
"); + sb.Append(""); + sb.Append("Cancel"); + sb.Append("
"); + + sb.Append(""); + + // JSON validation script + sb.Append(""); + sb.Append(""); + return sb.ToString(); + } + + // ===================== CSS ===================== + private string GetDashboardCSS() { return @" @@ -465,29 +530,29 @@ body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;paddi .btn{display:inline-block;padding:8px 16px;font-size:13px;border:none;border-radius:4px;cursor:pointer;text-decoration:none;color:#fff;} .btn-primary{background:#337AB7;} .btn-primary:hover{background:#286090;} .btn-default{background:#777;} .btn-default:hover{background:#555;} +.btn-disco{background:#5B2D8E;} .btn-disco:hover{background:#4A2475;} .btn-sm{padding:5px 10px;font-size:12px;} +.btn-lg{padding:10px 24px;font-size:14px;} .alert{margin:0 24px;padding:10px 16px;border-radius:4px;font-size:13px;margin-top:12px;} .alert-danger{background:#f8d7da;color:#721c24;border:1px solid #f5c6cb;} .alert-warning{background:#fff3cd;color:#856404;border:1px solid #ffeeba;} +.alert-success{background:#d4edda;color:#155724;border:1px solid #c3e6cb;} .stats-bar{display:flex;gap:12px;padding:16px 24px;flex-wrap:wrap;} .stat-card{text-align:center;padding:12px 18px;background:#fff;border-radius:6px;min-width:80px;box-shadow:0 1px 2px rgba(0,0,0,.06);text-decoration:none;color:inherit;transition:transform .15s;} .stat-card:hover{transform:translateY(-2px);box-shadow:0 3px 8px rgba(0,0,0,.12);} .stat-sep{border-left:2px solid #eee;margin-left:8px;padding-left:20px;} .stat-num{font-size:26px;font-weight:700;} .stat-lbl{font-size:11px;color:#888;margin-top:2px;} .location-bar{display:flex;gap:8px;padding:0 24px;flex-wrap:wrap;margin-bottom:8px;} -.loc-chip{display:inline-flex;align-items:center;gap:4px;padding:5px 12px;border-radius:16px;font-size:12px;color:#fff;text-decoration:none;transition:opacity .15s;} -.loc-chip:hover{opacity:.85;} +.loc-chip{display:inline-flex;align-items:center;gap:4px;padding:5px 12px;border-radius:16px;font-size:12px;color:#fff;text-decoration:none;transition:opacity .15s;} .loc-chip:hover{opacity:.85;} .controls{display:flex;align-items:center;gap:6px;padding:8px 24px;} .ctrl-label{font-size:12px;color:#888;margin-right:4px;} -.sort-btn{padding:5px 12px;border-radius:14px;font-size:12px;background:#e9ecef;color:#555;text-decoration:none;transition:background .15s;} -.sort-btn:hover{background:#d0d5db;} .sort-btn.active{background:#337AB7;color:#fff;} +.sort-btn{padding:5px 12px;border-radius:14px;font-size:12px;background:#e9ecef;color:#555;text-decoration:none;transition:background .15s;} .sort-btn:hover{background:#d0d5db;} .sort-btn.active{background:#337AB7;color:#fff;} .clear-btn{background:#DC3545;color:#fff;} .clear-btn:hover{background:#b52a3a;} .legend{display:flex;align-items:center;gap:12px;padding:4px 24px 12px;flex-wrap:wrap;} .legend-item{display:flex;align-items:center;gap:4px;font-size:12px;color:#666;} -.legend-dot{width:10px;height:10px;border-radius:50%;display:inline-block;} -.legend-item small{color:#aaa;} +.legend-dot{width:10px;height:10px;border-radius:50%;display:inline-block;} .legend-item small{color:#aaa;} .tile-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;padding:0 24px 24px;} -.tile{background:#fff;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.08);cursor:pointer;transition:box-shadow .15s,transform .15s;overflow:hidden;position:relative;} +.tile{background:#fff;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.08);cursor:pointer;transition:box-shadow .15s,transform .15s;overflow:hidden;} .tile:hover{box-shadow:0 4px 12px rgba(0,0,0,.15);transform:translateY(-2px);} .tile-breached{border:2px solid #DC3545;animation:pulse 2s infinite;} .tile-warning{border:2px solid #FFC107;} @@ -497,8 +562,7 @@ body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;paddi .tile-jobid{font-weight:700;font-size:15px;color:#337AB7;} .tile-age{font-size:11px;color:#888;padding:2px 8px;background:#f5f5f5;border-radius:10px;} .sla-badge{margin:0 14px 6px;padding:4px 8px;border-radius:4px;font-size:11px;font-weight:700;text-align:center;} -.sla-breached{background:#f8d7da;color:#721c24;} -.sla-warn{background:#fff3cd;color:#856404;} +.sla-breached{background:#f8d7da;color:#721c24;} .sla-warn{background:#fff3cd;color:#856404;} .tile-device{padding:0 14px 6px;} .tile-device-name{font-weight:600;font-size:14px;} .tile-device-model{font-size:11px;color:#888;} .tile-row{display:flex;align-items:center;gap:6px;padding:3px 14px;font-size:13px;} @@ -506,27 +570,20 @@ body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;paddi .tile-value{flex:1;} .tile-value strong{font-weight:600;} .tile-loc-badge{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:12px;font-size:11px;color:#fff;} .tile-summary{padding:6px 14px;font-size:12px;color:#555;border-top:1px solid #f0f0f0;font-style:italic;} -.tile-note{padding:6px 14px;font-size:11px;color:#777;background:#fafbfc;} -.note-icon{font-size:10px;} +.tile-note{padding:6px 14px;font-size:11px;color:#777;background:#fafbfc;} .note-icon{font-size:10px;} .tile-footer{display:flex;justify-content:space-between;align-items:center;padding:8px 14px;border-top:1px solid #f0f0f0;background:#fafbfc;} -.priority-label{display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;color:#fff;font-weight:600;} -.tile-type{font-size:11px;color:#aaa;} -.empty-state{grid-column:1/-1;text-align:center;padding:60px 20px;} -.empty-icon{font-size:48px;} .empty-msg{font-size:16px;color:#888;margin-top:12px;} -.workload-section{padding:0 24px 24px;} -.workload-section h3{font-size:14px;color:#888;margin-bottom:8px;} +.priority-label{display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;color:#fff;font-weight:600;} .tile-type{font-size:11px;color:#aaa;} +.empty-state{grid-column:1/-1;text-align:center;padding:60px 20px;} .empty-icon{font-size:48px;} .empty-msg{font-size:16px;color:#888;margin-top:12px;} +.workload-section{padding:0 24px 24px;} .workload-section h3{font-size:14px;color:#888;margin-bottom:8px;} .workload-bar{display:flex;gap:12px;flex-wrap:wrap;} .workload-item{background:#fff;border-radius:6px;padding:8px 16px;box-shadow:0 1px 2px rgba(0,0,0,.06);display:flex;align-items:center;gap:8px;} .wl-name{font-size:13px;} .wl-count{font-weight:700;font-size:16px;color:#337AB7;} .footer{text-align:center;padding:20px;font-size:12px;color:#aaa;} -.refresh-bar{display:flex;align-items:center;gap:10px;padding:8px 24px;background:#fff;border-top:1px solid #eee;position:sticky;bottom:0;z-index:10;} -.refresh-bar.paused{background:#fff3cd;} +.refresh-bar{display:flex;align-items:center;gap:10px;padding:8px 24px;background:#fff;border-top:1px solid #eee;position:sticky;bottom:0;z-index:10;} .refresh-bar.paused{background:#fff3cd;} .refresh-text{font-size:12px;color:#888;min-width:140px;} .refresh-progress{flex:1;height:4px;background:#e9ecef;border-radius:2px;overflow:hidden;} -.refresh-fill{height:100%;background:#337AB7;border-radius:2px;transition:width 1s linear;width:0%;} -.refresh-bar.paused .refresh-fill{background:#FFC107;} -.refresh-toggle{padding:4px 12px;font-size:11px;border:1px solid #ddd;border-radius:12px;background:#fff;cursor:pointer;color:#555;} -.refresh-toggle:hover{background:#f5f5f5;} +.refresh-fill{height:100%;background:#337AB7;border-radius:2px;transition:width 1s linear;width:0%;} .refresh-bar.paused .refresh-fill{background:#FFC107;} +.refresh-toggle{padding:4px 12px;font-size:11px;border:1px solid #ddd;border-radius:12px;background:#fff;cursor:pointer;color:#555;} .refresh-toggle:hover{background:#f5f5f5;} .muted{color:#999;font-style:italic;} "; } @@ -543,22 +600,41 @@ body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;paddi .detail-table{width:100%;border-collapse:collapse;} .detail-table th{text-align:left;padding:6px 8px;font-size:12px;color:#888;width:130px;vertical-align:top;} .detail-table td{padding:6px 8px;font-size:13px;} -.form-group{margin-bottom:12px;} -.form-group label{display:block;font-size:12px;font-weight:600;color:#555;margin-bottom:4px;} +.form-group{margin-bottom:12px;} .form-group label{display:block;font-size:12px;font-weight:600;color:#555;margin-bottom:4px;} .form-control{width:100%;padding:7px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;font-family:inherit;} .form-control:focus{border-color:#337AB7;outline:none;box-shadow:0 0 0 2px rgba(51,122,183,.15);} .form-control-sm{width:auto;padding:4px 8px;font-size:12px;} -.note-form{margin-bottom:16px;} -.note-controls{display:flex;gap:8px;margin-top:6px;align-items:center;} -.timeline{border-left:2px solid #e0e0e0;margin-left:8px;padding-left:0;} -.timeline-item{display:flex;gap:12px;padding:8px 0;position:relative;} +.note-form{margin-bottom:16px;} .note-controls{display:flex;gap:8px;margin-top:6px;align-items:center;} +.timeline{border-left:2px solid #e0e0e0;margin-left:8px;} +.timeline-item{display:flex;gap:12px;padding:8px 0;} .timeline-dot{width:10px;height:10px;border-radius:50%;margin-top:4px;flex-shrink:0;margin-left:-6px;} .timeline-content{flex:1;} .timeline-header{display:flex;gap:8px;align-items:center;font-size:12px;margin-bottom:3px;} -.timeline-author{font-weight:600;color:#333;} -.timeline-type{font-size:11px;text-transform:capitalize;} +.timeline-author{font-weight:600;color:#333;} .timeline-type{font-size:11px;text-transform:capitalize;} .timeline-date{color:#aaa;margin-left:auto;} .timeline-body{font-size:13px;color:#555;line-height:1.4;} +.changelog-table{width:100%;border-collapse:collapse;font-size:12px;} +.changelog-table th{text-align:left;padding:6px 8px;background:#f8f9fa;color:#888;font-weight:600;border-bottom:1px solid #eee;} +.changelog-table td{padding:5px 8px;border-bottom:1px solid #f5f5f5;} +.old-val{color:#DC3545;text-decoration:line-through;} .new-val{color:#28A745;font-weight:500;} +"; + } + + private string GetConfigCSS() + { + return @" +.config-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;padding:20px 24px;} +@media(max-width:900px){.config-grid{grid-template-columns:1fr;}} +.config-card{background:#fff;border-radius:8px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.08);} +.config-card h3{font-size:15px;color:#333;margin-bottom:12px;border-bottom:1px solid #eee;padding-bottom:8px;} +.config-help{font-size:12px;color:#888;margin-bottom:10px;line-height:1.4;} +.json-editor{font-family:'Consolas','Courier New',monospace;font-size:12px;line-height:1.5;background:#f8f9fa;border:1px solid #ddd;} +.json-editor:focus{border-color:#337AB7;background:#fff;} +.preview-section{margin-top:12px;padding-top:10px;border-top:1px solid #eee;} +.preview-section h4{font-size:12px;color:#888;margin-bottom:6px;} +.preview-chips{display:flex;gap:6px;flex-wrap:wrap;} +.preview-chip{display:inline-block;padding:3px 10px;border-radius:10px;font-size:11px;color:#fff;font-weight:500;} +.config-actions{padding:20px 24px;display:flex;gap:10px;} "; } }