Files

335 lines
40 KiB
C#

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;
using System.Web.Mvc;
namespace Disco.Plugins.ServiceTracker.WebHandler
{
public class ServiceTrackerWebHandler : PluginWebHandler
{
private ServiceTrackerDataStore GetDataStore()
{
return new ServiceTrackerDataStore(System.IO.Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "App_Data", "Plugins", "Disco.Plugins.ServiceTracker"));
}
public override ActionResult ExecuteAction(string ActionName)
{
var a = ActionName != null ? ActionName.ToLower() : "";
try
{
switch (a)
{
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();
case "markcollected": return MarkCollected();
default: return new HttpNotFoundResult();
}
}
catch (Exception ex) { return Err("Error: " + ex.GetType().Name + " - " + ex.Message + "\n" + ex.StackTrace); }
}
private ActionResult Dashboard()
{
var ds = GetDataStore(); var svc = new ServiceTrackerService(Database, ds);
return Html(BuildDashboardPage(svc.BuildDashboard(
HostController.Request.QueryString["priority"], HostController.Request.QueryString["location"],
HostController.Request.QueryString["status"], HostController.Request.QueryString["tech"],
HostController.Request.QueryString["sort"] ?? "due")));
}
private ActionResult UpdateTicket()
{
if (HostController.Request.HttpMethod != "POST") return new HttpStatusCodeResult(405);
var ds = GetDataStore(); var svc = new ServiceTrackerService(Database, ds);
int jobId; if (!int.TryParse(F("jobId"), out jobId)) return new HttpStatusCodeResult(400);
var src = F("source") ?? "disco";
DateTime? eta = null; DateTime ep; if (DateTime.TryParse(F("eta"), out ep)) eta = ep;
svc.UpdateTicket(jobId, src, F("priority"), F("location"), F("tech"), eta, F("status"), F("summary"), GetUser());
return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Detail?id=" + jobId + "&src=" + src);
}
private ActionResult AddNote()
{
if (HostController.Request.HttpMethod != "POST") return new HttpStatusCodeResult(405);
var ds = GetDataStore(); var svc = new ServiceTrackerService(Database, ds);
int jobId; if (!int.TryParse(F("jobId"), out jobId)) return new HttpStatusCodeResult(400);
var src = F("source") ?? "disco"; var user = GetUser();
svc.AddNote(jobId, src, user, svc.ResolveTechName(user) ?? user, F("note"), F("noteType") ?? "general");
return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Detail?id=" + jobId + "&src=" + src);
}
private ActionResult TicketDetail()
{
int jobId; if (!int.TryParse(HostController.Request.QueryString["id"], out jobId)) return new HttpStatusCodeResult(400);
var src = HostController.Request.QueryString["src"] ?? "disco";
var ds = GetDataStore(); var svc = new ServiceTrackerService(Database, ds); var cfg = ds.LoadConfig();
ServiceTicket ticket = svc.GetTicketDetail(jobId, src);
Disco.Models.Repository.Job job = null;
if (src == "disco")
{
job = Database.Jobs.Include("Device").Include("Device.DeviceModel").Include("User").Include("OpenedTechUser").Include("JobType").FirstOrDefault(j => j.Id == jobId);
if (job == null) return Err("Disco Job #" + jobId + " not found.");
}
return Html(BuildDetailPage(job, ticket, cfg, src, jobId));
}
private ActionResult ExportCsv()
{
var ds = GetDataStore(); var m = new ServiceTrackerService(Database, ds).BuildDashboard();
var sb = new StringBuilder("Id,Source,Device,User,Priority,Location,Status,Tech,Opened,ETA,Breached,Age,Summary\n");
foreach (var t in m.Tiles)
sb.AppendLine(string.Join(",", Q(t.DisplayId), Q(t.Source), Q(t.DeviceSerialNumber), Q(t.UserDisplayName),
Q(t.PriorityName), Q(t.LocationName), Q(t.StatusOverride), Q(t.AssignedTechName),
t.OpenedDate.ToString("yyyy-MM-dd"), t.EstimatedCompletion.HasValue ? t.EstimatedCompletion.Value.ToString("yyyy-MM-dd") : "",
t.IsSlaBreached, t.AgeDays, Q(t.Summary)));
HostController.Response.Headers.Add("Content-Disposition", "attachment; filename=\"ServiceTracker_" + DateTime.Now.ToString("yyyyMMdd") + ".csv\"");
return new ContentResult { Content = sb.ToString(), ContentType = "text/csv", ContentEncoding = Encoding.UTF8 };
}
private ActionResult ConfigEditor()
{
return Html(BuildConfigPage(GetDataStore().LoadConfig(), HostController.Request.QueryString["saved"] == "1", null));
}
private ActionResult SaveConfig()
{
if (HostController.Request.HttpMethod != "POST") return new HttpStatusCodeResult(405);
var ds = GetDataStore(); var cfg = ds.LoadConfig();
try
{
int r; if (int.TryParse(F("refreshSeconds"), out r) && r >= 10) cfg.DashboardRefreshSeconds = r;
int di; if (int.TryParse(F("inactivitySeconds"), out di) && di >= 60) cfg.DetailInactivitySeconds = di;
cfg.AutoCreateTicketsForNewJobs = F("autoCreate") == "on";
if (!string.IsNullOrEmpty(F("discoBaseUrl"))) cfg.DiscoBaseUrl = F("discoBaseUrl").TrimEnd('/');
if (!string.IsNullOrEmpty(F("defaultPriority"))) cfg.DefaultPriorityId = F("defaultPriority");
if (!string.IsNullOrEmpty(F("defaultLocation"))) cfg.DefaultLocationId = F("defaultLocation");
if (cfg.GoogleSheet == null) cfg.GoogleSheet = new GoogleSheetConfig();
cfg.GoogleSheet.Enabled = F("gsEnabled") == "on";
if (!string.IsNullOrEmpty(F("gsSpreadsheetId"))) cfg.GoogleSheet.SpreadsheetId = F("gsSpreadsheetId");
if (!string.IsNullOrEmpty(F("gsGid"))) cfg.GoogleSheet.GId = F("gsGid");
int gsr; if (int.TryParse(F("gsRefresh"), out gsr) && gsr >= 1) cfg.GoogleSheet.RefreshMinutes = gsr;
int c;
if (int.TryParse(F("gsColTimestamp"), out c)) cfg.GoogleSheet.ColTimestamp = c;
if (int.TryParse(F("gsColEmail"), out c)) cfg.GoogleSheet.ColEmail = c;
if (int.TryParse(F("gsColDevice"), out c)) cfg.GoogleSheet.ColDeviceName = c;
if (int.TryParse(F("gsColLocation"), out c)) cfg.GoogleSheet.ColLocation = c;
if (int.TryParse(F("gsColIssue"), out c)) cfg.GoogleSheet.ColIssue = c;
if (int.TryParse(F("gsColPriority"), out c)) cfg.GoogleSheet.ColPriority = c;
if (int.TryParse(F("gsColStatus"), out c)) cfg.GoogleSheet.ColStatus = c;
if (int.TryParse(F("gsColAssigned"), out c)) cfg.GoogleSheet.ColAssignedTo = c;
int hr; if (int.TryParse(F("gsHeaderRows"), out hr)) cfg.GoogleSheet.HeaderRows = hr;
var techs = PJL<TechEntry>(F("techsJson")); if (techs != null) cfg.Technicians = techs;
var pris = PJL<PriorityLevel>(F("prioritiesJson")); if (pris != null) cfg.Priorities = pris;
var locs = PJL<DeviceLocation>(F("locationsJson")); if (locs != null) cfg.Locations = locs;
var sr = F("statusOptions");
if (sr != null) cfg.StatusOptions = sr.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).Where(s => s.Length > 0).ToList();
ds.SaveConfig(cfg);
return Html(BuildConfigPage(cfg, true, null));
}
catch (Exception ex) { return Html(BuildConfigPage(cfg, false, "Save failed: " + ex.Message)); }
}
private ActionResult MarkCollected()
{
if (HostController.Request.HttpMethod != "POST") return new HttpStatusCodeResult(405);
var ds = GetDataStore(); var svc = new ServiceTrackerService(Database, ds);
int jobId; if (!int.TryParse(F("jobId"), out jobId)) return new HttpStatusCodeResult(400);
svc.MarkCollected(jobId, F("source") ?? "disco", GetUser());
return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Dashboard");
}
// Helpers
private string GetUser() { return (HostController.HttpContext.User != null && HostController.HttpContext.User.Identity != null) ? (HostController.HttpContext.User.Identity.Name ?? "system") : "system"; }
private ActionResult Html(string h) { return new ContentResult { Content = h, ContentType = "text/html", ContentEncoding = Encoding.UTF8 }; }
private ActionResult Err(string msg) { return Html("<!DOCTYPE html><html><body style='font-family:sans-serif;padding:40px;'><h2 style='color:#DC3545;'>Error</h2><pre>" + H(msg) + "</pre><a href='/Plugin/Disco.Plugins.ServiceTracker/Dashboard'>Dashboard</a></body></html>"); }
private string Q(string v) { return "\"" + (v ?? "").Replace("\"", "\"\"") + "\""; }
private string H(string v) { return string.IsNullOrEmpty(v) ? "" : HttpUtility.HtmlEncode(v); }
private string F(string n) { return HostController.Request.Form[n]; }
private List<T> PJL<T>(string json) { if (string.IsNullOrEmpty(json)) return null; try { var l = JsonConvert.DeserializeObject<List<T>>(json); return (l != null && l.Count > 0) ? l : null; } catch { return null; } }
// ===================== DASHBOARD =====================
private string BuildDashboardPage(DashboardViewModel m)
{
var u = "/Plugin/Disco.Plugins.ServiceTracker"; var s = new StringBuilder();
s.Append("<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Service Tracker</title><style>"); s.Append(CSS()); s.Append("</style></head><body>");
s.Append("<div class='header'><div class='hl'><h1>&#x1F6E0; Service Tracker</h1><span class='sub'>Open: <b>" + m.Stats.TotalOpen + "</b>");
if (m.Stats.FromGoogleSheet > 0) s.Append(" (+" + m.Stats.FromGoogleSheet + " NTT)");
s.Append("</span></div><div class='hr'><a href='" + u + "/Config' class='btn bd'>&#x2699;</a><a href='" + u + "/Export' class='btn bd'>&#x2B07;</a><a href='" + u + "/Dashboard' class='btn bp'>&#x21BB;</a></div></div>");
if (m.GoogleSheetError != null) s.Append("<div class='alert aw'>Sheet: " + H(m.GoogleSheetError) + "</div>");
if (m.Stats.SlaBreached > 0) s.Append("<div class='alert ad'>&#x26A0; <b>" + m.Stats.SlaBreached + " SLA BREACHED</b></div>");
if (m.Stats.SlaWarning > 0) s.Append("<div class='alert aw'>&#x23F0; " + m.Stats.SlaWarning + " approaching SLA</div>");
s.Append("<div class='sbar'>");
foreach (var p in m.Config.Priorities) { int ct; m.Stats.ByPriority.TryGetValue(p.Id, out ct); s.Append("<a href='" + u + "/Dashboard?priority=" + p.Id + "' class='sc' style='border-left:4px solid " + p.Color + ";'><div class='sn' style='color:" + p.Color + ";'>" + ct + "</div><div class='sl'>" + H(p.Name) + "</div></a>"); }
s.Append("<div class='sc ss'><div class='sn' style='color:#DC3545;'>" + m.Stats.SlaBreached + "</div><div class='sl'>Breached</div></div>");
s.Append("<div class='sc'><div class='sn'>" + m.Stats.AvgAgeDays.ToString("0.0") + "</div><div class='sl'>Avg Age</div></div></div>");
s.Append("<div class='lbar'>");
foreach (var l in m.Config.Locations) { int ct; m.Stats.ByLocation.TryGetValue(l.Id, out ct); if (ct > 0) s.Append("<a href='" + u + "/Dashboard?location=" + l.Id + "' class='lc' style='background:" + l.Color + ";'>" + l.Icon + " " + H(l.Name) + " <b>" + ct + "</b></a>"); }
s.Append("</div><div class='ctrls'><span class='cl'>Sort:</span>");
foreach (var o in new[] { "due|Due", "priority|Priority", "age|Age", "sla|SLA", "modified|Updated" }) { var p = o.Split('|'); s.Append("<a href='" + u + "/Dashboard?sort=" + p[0] + "' class='sb" + (p[0] == m.SortBy ? " act" : "") + "'>" + p[1] + "</a>"); }
if (!string.IsNullOrEmpty(m.CurrentFilter)) s.Append("<a href='" + u + "/Dashboard' class='sb clr'>&#x2716;</a>");
s.Append("</div><div class='dash-layout'><div class='tile-grid'>");
if (m.Tiles.Count == 0) s.Append("<div class='empty'>&#x2705; No open jobs</div>");
else foreach (var t in m.Tiles) s.Append(TileHtml(t, u));
s.Append("</div><div class='rfr-panel'><h3>&#x1F4E6; Ready for Return</h3>");
if (m.ReadyForReturn.Count == 0) s.Append("<p class='mu'>None</p>");
else foreach (var r in m.ReadyForReturn) { s.Append("<div class='rfr-item'><div class='rfr-id'>" + H(r.DisplayId) + "</div><div class='rfr-dev'>" + H(r.DeviceComputerName ?? r.DeviceSerialNumber) + "</div><div class='rfr-user'>" + H(r.UserDisplayName) + "</div><form method='POST' action='" + u + "/MarkCollected'><input type='hidden' name='jobId' value='" + r.JobId + "'/><input type='hidden' name='source' value='" + r.Source + "'/><button type='submit' class='btn bp btn-sm'>&#x2714; Collected</button></form></div>"); }
s.Append("</div></div>");
if (m.Stats.ByTech.Count > 0) { s.Append("<div class='wls'><h3>Tech Workload</h3><div class='wlb'>"); foreach (var kv in m.Stats.ByTech.OrderByDescending(x => x.Value)) s.Append("<div class='wli'><span>" + H(kv.Key) + "</span><b style='color:#337AB7;font-size:15px;'>" + kv.Value + "</b></div>"); s.Append("</div></div>"); }
var rs = m.Config.DashboardRefreshSeconds;
s.Append("<div class='rbar' id='rB'><span class='rtx'>Refresh: <b id='cd'>" + rs + "</b>s</span><div class='rprog'><div class='rfill' id='rF'></div></div><button class='rtog' id='rT' onclick='tAR()'>Pause</button></div>");
s.Append("<div class='foot'>v" + ServiceTrackerService.PluginVersion + "</div>");
s.Append("<script>var T=" + rs + ",R=" + rs + ",P=false;function tk(){if(P)return;R--;document.getElementById('cd').textContent=R;document.getElementById('rF').style.width=((T-R)/T*100)+'%';if(R<=0)location.reload();}function tAR(){P=!P;document.getElementById('rT').textContent=P?'Resume':'Pause';document.getElementById('rB').className=P?'rbar pau':'rbar';}setInterval(tk,1000);document.addEventListener('visibilitychange',function(){if(document.hidden)P=true;else{P=false;R=T;}});</script>");
s.Append("</body></html>"); return s.ToString();
}
private string TileHtml(DashboardTile t, string u)
{
var s = new StringBuilder();
s.Append("<div class='tile" + (t.IsSlaBreached ? " tb" : t.IsSlaWarning ? " tw" : "") + "' onclick=\"location='" + u + "/Detail?id=" + t.JobId + "&src=" + t.Source + "'\">");
s.Append("<div class='tp' style='background:" + t.PriorityColor + ";'></div>");
s.Append("<div class='th'><div class='tid'>" + H(t.DisplayId) + "</div><div class='ta'>" + t.AgeBadge + "</div></div>");
if (t.IsSlaBreached) s.Append("<div class='slb slb-b'>&#x26A0; SLA BREACHED</div>");
else if (t.IsSlaWarning) s.Append("<div class='slb slb-w'>&#x23F0; Warning</div>");
s.Append("<div class='td'><div class='tdn'>" + H(t.DeviceComputerName ?? t.DeviceSerialNumber) + "</div>");
if (t.DeviceModelDescription != null) s.Append("<div class='tdm'>" + H(t.DeviceModelDescription) + "</div>");
s.Append("</div><div class='tr'><span class='ti'>&#x1F464;</span>" + H(t.UserDisplayName ?? "\u2014") + "</div>");
s.Append("<div class='tr'><span class='tlb' style='background:" + t.LocationColor + ";'>" + t.LocationIcon + " " + H(t.LocationName) + "</span></div>");
s.Append("<div class='tr'><span class='ti'>&#x1F4CB;</span>" + H(t.StatusOverride ?? t.DiscoStatus) + "</div>");
if (!string.IsNullOrEmpty(t.AssignedTechName)) s.Append("<div class='tr'><span class='ti'>&#x1F527;</span>" + H(t.AssignedTechName) + "</div>");
s.Append("<div class='tr'><span class='ti'>&#x1F4C5;</span>ETA: <b>" + H(t.EtaDisplay) + "</b></div>");
if (!string.IsNullOrEmpty(t.Summary)) { var sm = t.Summary.Length > 60 ? t.Summary.Substring(0, 57) + "..." : t.Summary; s.Append("<div class='tsu'>" + H(sm) + "</div>"); }
if (!string.IsNullOrEmpty(t.LatestNote)) { s.Append("<div class='tn'>&#x1F4AC; " + H(t.LatestNote)); if (t.NoteCount > 1) s.Append(" <small>(+" + (t.NoteCount - 1) + ")</small>"); s.Append("</div>"); }
s.Append("<div class='tf'><span class='pl' style='background:" + t.PriorityColor + ";'>" + H(t.PriorityName) + "</span><span class='tt'>" + H(t.Source == "ntt" ? "NTT" : t.JobTypeDescription) + "</span></div></div>");
return s.ToString();
}
// ===================== DETAIL =====================
private string BuildDetailPage(Disco.Models.Repository.Job job, ServiceTicket ticket, ServiceTrackerConfig cfg, string src, int jobId)
{
var u = "/Plugin/Disco.Plugins.ServiceTracker"; var s = new StringBuilder();
var did = src == "ntt" ? "NTT#" + jobId : "DIC#" + jobId;
s.Append("<!DOCTYPE html><html><head><meta charset='utf-8'/><title>" + did + "</title><style>" + CSS() + DCSS() + "</style></head><body>");
s.Append("<div class='header'><div class='hl'><a href='" + u + "/Dashboard' class='bk'>&larr; Dashboard</a><h1>" + H(did) + "</h1></div><div class='hr'>");
if (src == "disco") s.Append("<a href='" + H(cfg.DiscoBaseUrl) + "/Job/Show/" + jobId + "' class='btn bdi' target='_blank'>&#x1F4C2; Open in Disco</a>");
s.Append("</div></div><div class='dg'><div class='dl'><div class='dc'><h3>Details</h3><table class='dt'>");
if (job != null)
{
var di2 = job.Device != null ? job.Device.DeviceDomainId : null;
s.Append("<tr><th>Device</th><td>" + H(job.DeviceSerialNumber) + (di2 != null ? " (" + H(di2) + ")" : "") + "</td></tr>");
s.Append("<tr><th>Model</th><td>" + H(job.Device != null && job.Device.DeviceModel != null ? job.Device.DeviceModel.Description : null) + "</td></tr>");
s.Append("<tr><th>User</th><td>" + H(job.User != null ? job.User.DisplayName : job.UserId) + "</td></tr>");
s.Append("<tr><th>Type</th><td>" + H(job.JobType != null ? job.JobType.Description : job.JobTypeId) + "</td></tr>");
s.Append("<tr><th>Opened</th><td>" + job.OpenedDate.ToString("dd MMM yyyy HH:mm") + " by " + H(job.OpenedTechUser != null ? job.OpenedTechUser.DisplayName : job.OpenedTechUserId) + "</td></tr>");
if (job.ExpectedClosedDate.HasValue) s.Append("<tr><th>Expected</th><td>" + job.ExpectedClosedDate.Value.ToString("dd MMM yyyy") + "</td></tr>");
}
else
{
s.Append("<tr><th>Source</th><td>NTT Google Sheet</td></tr>");
if (ticket != null) { s.Append("<tr><th>Summary</th><td>" + H(ticket.Summary) + "</td></tr>"); s.Append("<tr><th>Created</th><td>" + ticket.CreatedDate.ToString("dd MMM yyyy HH:mm") + "</td></tr>"); }
}
s.Append("</table></div>");
var tP = (ticket != null ? ticket.PriorityId : null) ?? cfg.DefaultPriorityId;
var tL = (ticket != null ? ticket.LocationId : null) ?? cfg.DefaultLocationId;
var tS = ticket != null ? ticket.StatusOverride : null;
var tT = ticket != null ? ticket.AssignedTechId : "";
var tSu = ticket != null ? ticket.Summary : "";
var tE = (ticket != null && ticket.EstimatedCompletion.HasValue) ? ticket.EstimatedCompletion.Value.ToString("yyyy-MM-dd") : "";
s.Append("<div class='dc'><h3>Settings</h3><form method='POST' action='" + u + "/Update'><input type='hidden' name='jobId' value='" + jobId + "'/><input type='hidden' name='source' value='" + src + "'/>");
s.Append("<div class='fg'><label>Priority</label><select name='priority' class='fc'>");
foreach (var p in cfg.Priorities) s.Append("<option value='" + p.Id + "'" + (tP == p.Id ? " selected" : "") + ">" + H(p.Name) + " (" + p.SlaHours + "h)</option>");
s.Append("</select></div><div class='fg'><label>Location</label><select name='location' class='fc'>");
foreach (var l in cfg.Locations) s.Append("<option value='" + l.Id + "'" + (tL == l.Id ? " selected" : "") + ">" + l.Icon + " " + H(l.Name) + "</option>");
s.Append("</select></div><div class='fg'><label>Status</label><select name='status' class='fc'><option value=''>-- Default --</option>");
foreach (var st in cfg.StatusOptions) s.Append("<option value='" + H(st) + "'" + (tS == st ? " selected" : "") + ">" + H(st) + "</option>");
s.Append("</select></div><div class='fg'><label>Assigned Tech</label><select name='tech' class='fc'><option value=''>-- Unassigned --</option>");
foreach (var t in cfg.Technicians) s.Append("<option value='" + H(t.Id) + "'" + (tT == t.Id ? " selected" : "") + ">" + H(t.DisplayName) + "</option>");
s.Append("</select></div>");
s.Append("<div class='fg'><label>ETA</label><input type='date' name='eta' class='fc' value='" + tE + "'/></div>");
s.Append("<div class='fg'><label>Summary</label><textarea name='summary' class='fc' rows='3'>" + H(tSu ?? "") + "</textarea></div>");
s.Append("<button type='submit' class='btn bp'>&#x2714; Save</button></form></div></div>");
s.Append("<div class='dr'><div class='dc'><h3>Activity</h3><form method='POST' action='" + u + "/AddNote' class='nf'><input type='hidden' name='jobId' value='" + jobId + "'/><input type='hidden' name='source' value='" + src + "'/>");
s.Append("<textarea name='note' class='fc' rows='2' placeholder='Add a note...' required></textarea><div class='nc'><select name='noteType' class='fc fcsm'><option value='general'>General</option><option value='update'>Update</option><option value='escalation'>Escalation</option><option value='resolution'>Resolution</option></select><button type='submit' class='btn bp btn-sm'>Add</button></div></form>");
if (ticket != null && ticket.Notes != null && ticket.Notes.Count > 0) { s.Append("<div class='tl'>"); foreach (var n in ticket.Notes.OrderByDescending(n => n.Timestamp)) { var tc = "#337AB7"; switch (n.NoteType) { case "escalation": tc = "#DC3545"; break; case "resolution": tc = "#28A745"; break; case "update": tc = "#FFC107"; break; } s.Append("<div class='tli'><div class='tld' style='background:" + tc + ";'></div><div class='tlc'><div class='tlh'><span class='tla'>" + H(n.AuthorName ?? n.AuthorId) + "</span><span style='color:" + tc + ";font-size:10px;'>" + H(n.NoteType) + "</span><span class='tldt'>" + n.Timestamp.ToString("dd MMM HH:mm") + "</span></div><div class='tlb'>" + H(n.Content) + "</div></div></div>"); } s.Append("</div>"); } else s.Append("<p class='mu'>No notes yet.</p>");
s.Append("</div>");
if (ticket != null && ticket.ChangeLog != null && ticket.ChangeLog.Count > 0) { s.Append("<div class='dc'><h3>Changes</h3><table class='clt'><tr><th>When</th><th>Who</th><th>Field</th><th>From</th><th>To</th></tr>"); foreach (var ch in ticket.ChangeLog.OrderByDescending(c => c.Timestamp)) s.Append("<tr><td>" + ch.Timestamp.ToString("dd MMM HH:mm") + "</td><td>" + H(ch.UserId) + "</td><td><b>" + H(ch.Field) + "</b></td><td class='ov'>" + H(ch.OldValue ?? "-") + "</td><td class='nv'>" + H(ch.NewValue ?? "-") + "</td></tr>"); s.Append("</table></div>"); }
s.Append("</div></div>");
var inact = cfg.DetailInactivitySeconds;
s.Append("<div class='foot'>v" + ServiceTrackerService.PluginVersion + "</div>");
s.Append("<script>var _c=" + inact + ";function _r(){_c=" + inact + ";}['mousemove','keydown','click','scroll'].forEach(function(e){document.addEventListener(e,_r);});setInterval(function(){_c--;if(_c<=0)location.href='" + u + "/Dashboard';},1000);</script>");
s.Append("</body></html>"); return s.ToString();
}
// ===================== CONFIG =====================
private string BuildConfigPage(ServiceTrackerConfig cfg, bool saved, string error)
{
var u = "/Plugin/Disco.Plugins.ServiceTracker"; var s = new StringBuilder();
s.Append("<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Config</title><style>" + CSS() + DCSS() + CCSS() + "</style></head><body>");
s.Append("<div class='header'><div class='hl'><a href='" + u + "/Dashboard' class='bk'>&larr; Dashboard</a><h1>&#x2699; Configuration</h1><span class='sub'>v" + ServiceTrackerService.PluginVersion + "</span></div></div>");
if (saved) s.Append("<div class='alert as'>&#x2705; Saved!</div>");
if (error != null) s.Append("<div class='alert ad'>" + H(error) + "</div>");
s.Append("<form method='POST' action='" + u + "/SaveConfig'><div class='cg'>");
s.Append("<div class='cc'><h3>General</h3>");
s.Append("<div class='fg'><label>Disco Base URL</label><input name='discoBaseUrl' class='fc' value='" + H(cfg.DiscoBaseUrl) + "'/></div>");
s.Append("<div class='fg'><label>Dashboard Refresh (sec)</label><input type='number' name='refreshSeconds' class='fc' value='" + cfg.DashboardRefreshSeconds + "' min='10'/></div>");
s.Append("<div class='fg'><label>Detail Inactivity (sec)</label><input type='number' name='inactivitySeconds' class='fc' value='" + cfg.DetailInactivitySeconds + "' min='60'/></div>");
s.Append("<div class='fg'><label><input type='checkbox' name='autoCreate'" + (cfg.AutoCreateTicketsForNewJobs ? " checked" : "") + "/> Auto-create for Disco jobs</label></div>");
s.Append("<div class='fg'><label>Default Priority</label><select name='defaultPriority' class='fc'>");
foreach (var p in cfg.Priorities) s.Append("<option value='" + p.Id + "'" + (cfg.DefaultPriorityId == p.Id ? " selected" : "") + ">" + H(p.Name) + "</option>");
s.Append("</select></div><div class='fg'><label>Default Location</label><select name='defaultLocation' class='fc'>");
foreach (var l in cfg.Locations) s.Append("<option value='" + l.Id + "'" + (cfg.DefaultLocationId == l.Id ? " selected" : "") + ">" + l.Icon + " " + H(l.Name) + "</option>");
s.Append("</select></div></div>");
var gs = cfg.GoogleSheet ?? new GoogleSheetConfig();
s.Append("<div class='cc'><h3>&#x1F4C4; Google Sheet (NTT)</h3>");
s.Append("<div class='fg'><label><input type='checkbox' name='gsEnabled'" + (gs.Enabled ? " checked" : "") + "/> Enable</label></div>");
s.Append("<div class='fg'><label>Spreadsheet ID</label><input name='gsSpreadsheetId' class='fc' value='" + H(gs.SpreadsheetId) + "'/></div>");
s.Append("<div class='fg'><label>Tab GID</label><input name='gsGid' class='fc' value='" + H(gs.GId) + "'/></div>");
s.Append("<div class='fg'><label>Refresh (min)</label><input type='number' name='gsRefresh' class='fc' value='" + gs.RefreshMinutes + "' min='1'/></div>");
s.Append("<div class='fg'><label>Header Rows</label><input type='number' name='gsHeaderRows' class='fc' value='" + gs.HeaderRows + "'/></div>");
s.Append("<p class='ch'>Column indices (0-based):</p><div class='col-grid'>");
s.Append("<div class='fg'><label>Timestamp</label><input type='number' name='gsColTimestamp' class='fc' value='" + gs.ColTimestamp + "'/></div>");
s.Append("<div class='fg'><label>Email</label><input type='number' name='gsColEmail' class='fc' value='" + gs.ColEmail + "'/></div>");
s.Append("<div class='fg'><label>Device</label><input type='number' name='gsColDevice' class='fc' value='" + gs.ColDeviceName + "'/></div>");
s.Append("<div class='fg'><label>Location</label><input type='number' name='gsColLocation' class='fc' value='" + gs.ColLocation + "'/></div>");
s.Append("<div class='fg'><label>Issue</label><input type='number' name='gsColIssue' class='fc' value='" + gs.ColIssue + "'/></div>");
s.Append("<div class='fg'><label>Priority</label><input type='number' name='gsColPriority' class='fc' value='" + gs.ColPriority + "'/></div>");
s.Append("<div class='fg'><label>Status</label><input type='number' name='gsColStatus' class='fc' value='" + gs.ColStatus + "'/></div>");
s.Append("<div class='fg'><label>Assigned</label><input type='number' name='gsColAssigned' class='fc' value='" + gs.ColAssignedTo + "'/></div>");
s.Append("</div><p class='ch'>Publish sheet as CSV: File &gt; Share &gt; Publish to web.</p></div>");
s.Append("<div class='cc'><h3>&#x1F527; Technicians</h3><div id='tL'>");
foreach (var t in cfg.Technicians) { var ids = t.DiscoUserIds != null ? string.Join(", ", t.DiscoUserIds) : ""; s.Append("<div class='tech-row'><input class='fc tc-n' value='" + H(t.DisplayName) + "' placeholder='Name'/><input class='fc tc-i' value='" + H(ids) + "' placeholder='Disco IDs'/><input class='fc tc-e' value='" + H(t.Email) + "' placeholder='Email'/><button type='button' class='btn-rm' onclick='this.parentNode.remove()'>&#x2716;</button></div>"); }
s.Append("</div><button type='button' class='btn bd btn-sm' onclick='aT()'>+ Add Tech</button><textarea name='techsJson' id='tO' class='hid'></textarea></div>");
s.Append("<div class='cc'><h3>Priorities</h3><textarea name='prioritiesJson' class='fc je' rows='12'>" + H(JsonConvert.SerializeObject(cfg.Priorities, Formatting.Indented)) + "</textarea><div class='pv'>");
foreach (var p in cfg.Priorities.OrderBy(x => x.SortOrder)) s.Append("<span class='pc' style='background:" + p.Color + ";'>" + H(p.Name) + " (" + p.SlaHours + "h)</span>");
s.Append("</div></div><div class='cc'><h3>Locations</h3><textarea name='locationsJson' class='fc je' rows='12'>" + H(JsonConvert.SerializeObject(cfg.Locations, Formatting.Indented)) + "</textarea><div class='pv'>");
foreach (var l in cfg.Locations) s.Append("<span class='pc' style='background:" + l.Color + ";'>" + l.Icon + " " + H(l.Name) + "</span>");
s.Append("</div></div><div class='cc'><h3>Status Options</h3><textarea name='statusOptions' class='fc' rows='10'>");
foreach (var st in cfg.StatusOptions) s.Append(H(st) + "\n");
s.Append("</textarea></div></div>");
s.Append("<div class='ca'><button type='submit' class='btn bp btn-lg'>&#x2714; Save</button> <a href='" + u + "/Dashboard' class='btn bd btn-lg'>Cancel</a></div></form>");
s.Append("<div class='foot'>v" + ServiceTrackerService.PluginVersion + "</div>");
s.Append("<script>function aT(){var d=document.getElementById('tL'),r=document.createElement('div');r.className='tech-row';r.innerHTML='<input class=\"fc tc-n\" placeholder=\"Name\"/><input class=\"fc tc-i\" placeholder=\"Disco IDs\"/><input class=\"fc tc-e\" placeholder=\"Email\"/><button type=\"button\" class=\"btn-rm\" onclick=\"this.parentNode.remove()\">&#x2716;</button>';d.appendChild(r);}");
s.Append("document.querySelector('form').addEventListener('submit',function(e){var rows=document.querySelectorAll('.tech-row'),ts=[];for(var i=0;i<rows.length;i++){var r=rows[i],n=r.querySelector('.tc-n').value.trim();if(!n)continue;var ids=(r.querySelector('.tc-i').value||'').split(',').map(function(s){return s.trim();}).filter(function(s){return s.length>0;});ts.push({Id:n.toLowerCase().replace(/[^a-z]/g,'').substring(0,10),DisplayName:n,DiscoUserIds:ids,Email:r.querySelector('.tc-e').value.trim()});}document.getElementById('tO').value=JSON.stringify(ts);var jes=document.querySelectorAll('.je');for(var j=0;j<jes.length;j++){try{JSON.parse(jes[j].value);}catch(ex){e.preventDefault();jes[j].style.borderColor='#DC3545';alert('Invalid JSON: '+ex.message);return;}}});</script>");
s.Append("</body></html>"); return s.ToString();
}
// ===================== CSS =====================
private string CSS() { return @"*{box-sizing:border-box;margin:0;padding:0}body{font-family:'Segoe UI',sans-serif;background:#f0f2f5;color:#333}.header{display:flex;justify-content:space-between;align-items:center;padding:14px 20px;background:#fff;border-bottom:2px solid #337AB7;box-shadow:0 1px 3px rgba(0,0,0,.08)}.hl{display:flex;align-items:center;gap:14px}.hr{display:flex;gap:6px}h1{font-size:20px;color:#333}.sub{color:#666;font-size:13px}.btn{display:inline-block;padding:7px 14px;font-size:12px;border:none;border-radius:4px;cursor:pointer;text-decoration:none;color:#fff}.bp{background:#337AB7}.bp:hover{background:#286090}.bd{background:#777}.bd:hover{background:#555}.bdi{background:#5B2D8E}.bdi:hover{background:#4A2475}.btn-sm{padding:4px 10px;font-size:11px}.btn-lg{padding:10px 20px;font-size:14px}.alert{margin:8px 20px;padding:8px 14px;border-radius:4px;font-size:12px}.ad{background:#f8d7da;color:#721c24;border:1px solid #f5c6cb}.aw{background:#fff3cd;color:#856404;border:1px solid #ffeeba}.as{background:#d4edda;color:#155724;border:1px solid #c3e6cb}.sbar{display:flex;gap:10px;padding:14px 20px;flex-wrap:wrap}.sc{text-align:center;padding:10px 16px;background:#fff;border-radius:6px;min-width:70px;box-shadow:0 1px 2px rgba(0,0,0,.06);text-decoration:none;color:inherit;transition:transform .15s}.sc:hover{transform:translateY(-2px)}.ss{border-left:2px solid #eee;margin-left:6px;padding-left:18px}.sn{font-size:24px;font-weight:700}.sl{font-size:10px;color:#888;margin-top:2px}.lbar{display:flex;gap:6px;padding:0 20px;flex-wrap:wrap;margin-bottom:6px}.lc{display:inline-flex;align-items:center;gap:3px;padding:4px 10px;border-radius:14px;font-size:11px;color:#fff;text-decoration:none}.lc:hover{opacity:.85}.ctrls{display:flex;align-items:center;gap:5px;padding:6px 20px}.cl{font-size:11px;color:#888;margin-right:3px}.sb{padding:4px 10px;border-radius:12px;font-size:11px;background:#e9ecef;color:#555;text-decoration:none}.sb:hover{background:#d0d5db}.sb.act{background:#337AB7;color:#fff}.clr{background:#DC3545;color:#fff}.dash-layout{display:flex;gap:16px;padding:12px 20px;align-items:flex-start}.tile-grid{flex:1;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}.rfr-panel{width:240px;flex-shrink:0;background:#fff;border-radius:8px;padding:14px;box-shadow:0 1px 3px rgba(0,0,0,.08)}.rfr-panel h3{font-size:14px;margin-bottom:10px;color:#333;border-bottom:1px solid #eee;padding-bottom:6px}.rfr-item{padding:8px;border:1px solid #eee;border-radius:6px;margin-bottom:8px;background:#fafbfc}.rfr-id{font-weight:700;font-size:12px;color:#337AB7}.rfr-dev{font-size:13px;font-weight:500}.rfr-user{font-size:11px;color:#888}.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)}.tb{border:2px solid #DC3545;animation:pu 2s infinite}.tw{border:2px solid #FFC107}@keyframes pu{0%,100%{border-color:#DC3545}50%{border-color:#f8d7da}}.tp{height:4px;width:100%}.th{display:flex;justify-content:space-between;padding:8px 12px 3px}.tid{font-weight:700;font-size:14px;color:#337AB7}.ta{font-size:10px;color:#888;padding:2px 6px;background:#f5f5f5;border-radius:8px}.slb{margin:0 12px 4px;padding:3px 6px;border-radius:3px;font-size:10px;font-weight:700;text-align:center}.slb-b{background:#f8d7da;color:#721c24}.slb-w{background:#fff3cd;color:#856404}.td{padding:0 12px 4px}.tdn{font-weight:600;font-size:13px}.tdm{font-size:10px;color:#888}.tr{display:flex;align-items:center;gap:5px;padding:2px 12px;font-size:12px}.ti{width:16px;text-align:center;font-size:11px}.tlb{display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:10px;font-size:10px;color:#fff}.tsu{padding:4px 12px;font-size:11px;color:#555;border-top:1px solid #f0f0f0;font-style:italic}.tn{padding:4px 12px;font-size:10px;color:#777;background:#fafbfc}.tf{display:flex;justify-content:space-between;align-items:center;padding:6px 12px;border-top:1px solid #f0f0f0;background:#fafbfc}.pl{display:inline-block;padding:2px 8px;border-radius:8px;font-size:10px;color:#fff;font-weight:600}.tt{font-size:10px;color:#aaa}.empty{grid-column:1/-1;text-align:center;padding:50px;color:#888}.wls{padding:0 20px 16px}.wls h3{font-size:13px;color:#888;margin-bottom:6px}.wlb{display:flex;gap:10px;flex-wrap:wrap}.wli{background:#fff;border-radius:6px;padding:6px 14px;box-shadow:0 1px 2px rgba(0,0,0,.06);display:flex;align-items:center;gap:6px;font-size:12px}.foot{text-align:center;padding:16px;font-size:11px;color:#aaa}.rbar{display:flex;align-items:center;gap:8px;padding:6px 20px;background:#fff;border-top:1px solid #eee;position:sticky;bottom:0;z-index:10}.rbar.pau{background:#fff3cd}.rtx{font-size:11px;color:#888;min-width:100px}.rprog{flex:1;height:3px;background:#e9ecef;border-radius:2px;overflow:hidden}.rfill{height:100%;background:#337AB7;border-radius:2px;transition:width 1s linear;width:0%}.rbar.pau .rfill{background:#FFC107}.rtog{padding:3px 10px;font-size:10px;border:1px solid #ddd;border-radius:10px;background:#fff;cursor:pointer;color:#555}.mu{color:#999;font-style:italic;font-size:12px}@media(max-width:900px){.dash-layout{flex-direction:column}.rfr-panel{width:100%}}"; }
private string DCSS() { return @".bk{font-size:12px;color:#337AB7;text-decoration:none}.bk:hover{text-decoration:underline}.dg{display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:16px 20px}@media(max-width:900px){.dg{grid-template-columns:1fr}}.dl,.dr{display:flex;flex-direction:column;gap:14px}.dc{background:#fff;border-radius:8px;padding:16px;box-shadow:0 1px 3px rgba(0,0,0,.08)}.dc h3{font-size:14px;color:#333;margin-bottom:10px;border-bottom:1px solid #eee;padding-bottom:6px}.dt{width:100%;border-collapse:collapse}.dt th{text-align:left;padding:5px 6px;font-size:11px;color:#888;width:120px;vertical-align:top}.dt td{padding:5px 6px;font-size:12px}.fg{margin-bottom:10px}.fg label{display:block;font-size:11px;font-weight:600;color:#555;margin-bottom:3px}.fc{width:100%;padding:6px 8px;border:1px solid #ddd;border-radius:4px;font-size:12px;font-family:inherit}.fc:focus{border-color:#337AB7;outline:none}.fcsm{width:auto;padding:3px 6px;font-size:11px}.nf{margin-bottom:12px}.nc{display:flex;gap:6px;margin-top:4px;align-items:center}.tl{border-left:2px solid #e0e0e0;margin-left:6px}.tli{display:flex;gap:10px;padding:6px 0}.tld{width:8px;height:8px;border-radius:50%;margin-top:3px;flex-shrink:0;margin-left:-5px}.tlc{flex:1}.tlh{display:flex;gap:6px;align-items:center;font-size:11px;margin-bottom:2px}.tla{font-weight:600;color:#333}.tldt{color:#aaa;margin-left:auto}.tlb{font-size:12px;color:#555;line-height:1.3}.clt{width:100%;border-collapse:collapse;font-size:11px}.clt th{text-align:left;padding:4px 6px;background:#f8f9fa;color:#888;font-weight:600;border-bottom:1px solid #eee}.clt td{padding:4px 6px;border-bottom:1px solid #f5f5f5}.ov{color:#DC3545;text-decoration:line-through}.nv{color:#28A745;font-weight:500}"; }
private string CCSS() { return @".cg{display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:16px 20px}@media(max-width:900px){.cg{grid-template-columns:1fr}}.cc{background:#fff;border-radius:8px;padding:16px;box-shadow:0 1px 3px rgba(0,0,0,.08)}.cc h3{font-size:14px;color:#333;margin-bottom:10px;border-bottom:1px solid #eee;padding-bottom:6px}.ch{font-size:11px;color:#888;margin:6px 0;line-height:1.3}.je{font-family:'Consolas',monospace;font-size:11px;line-height:1.4;background:#f8f9fa}.je:focus{background:#fff}.pv{margin-top:8px;display:flex;gap:4px;flex-wrap:wrap}.pc{display:inline-block;padding:2px 8px;border-radius:8px;font-size:10px;color:#fff;font-weight:500}.ca{padding:16px 20px;display:flex;gap:8px}.col-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}.tech-row{display:flex;gap:4px;margin-bottom:4px;align-items:center}.tech-row .fc{flex:1;font-size:11px;padding:4px 6px}.btn-rm{background:none;border:none;color:#DC3545;cursor:pointer;font-size:14px;padding:0 4px}.btn-rm:hover{color:#a71d2a}.hid{display:none}"; }
}
}