From 495cecf1b5024df2d56755ecf34f1828ec561602 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Wed, 6 May 2026 14:46:21 +1000 Subject: [PATCH] feat: Disco link, DIC#/NTT#, tech dropdown, GUI config, Ready for Return sidebar, inactivity reload, Google Sheet UI --- WebHandler/ServiceTrackerWebHandler.cs | 968 ++++++++++++------------- 1 file changed, 463 insertions(+), 505 deletions(-) diff --git a/WebHandler/ServiceTrackerWebHandler.cs b/WebHandler/ServiceTrackerWebHandler.cs index a07e110..d7884e2 100644 --- a/WebHandler/ServiceTrackerWebHandler.cs +++ b/WebHandler/ServiceTrackerWebHandler.cs @@ -15,627 +15,585 @@ namespace Disco.Plugins.ServiceTracker.WebHandler { private ServiceTrackerDataStore GetDataStore() { - var dataPath = System.IO.Path.Combine( - AppDomain.CurrentDomain.BaseDirectory, "App_Data", "Plugins", "Disco.Plugins.ServiceTracker"); - return new ServiceTrackerDataStore(dataPath); + return new ServiceTrackerDataStore(System.IO.Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "App_Data", "Plugins", "Disco.Plugins.ServiceTracker")); } public override ActionResult ExecuteAction(string ActionName) { - var action = ActionName != null ? ActionName.ToLower() : ""; - switch (action) + var a = ActionName != null ? ActionName.ToLower() : ""; + try { - 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(); + 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 in action '" + a + "': " + ex.GetType().Name + " - " + ex.Message + "\n" + ex.StackTrace); } } + // ===================== ACTIONS ===================== + private ActionResult Dashboard() { - var dataStore = GetDataStore(); - var service = new ServiceTrackerService(Database, dataStore); - var filterPriority = HostController.Request.QueryString["priority"]; - var filterLocation = HostController.Request.QueryString["location"]; - var filterStatus = HostController.Request.QueryString["status"]; - var filterTech = HostController.Request.QueryString["tech"]; - var sortBy = HostController.Request.QueryString["sort"] ?? "due"; - var model = service.BuildDashboard(filterPriority, filterLocation, filterStatus, filterTech, sortBy); - return HtmlResult(BuildDashboardPage(model)); + var ds = GetDataStore(); var svc = new ServiceTrackerService(Database, ds); + var fp = HostController.Request.QueryString["priority"]; + var fl = HostController.Request.QueryString["location"]; + var fs = HostController.Request.QueryString["status"]; + var ft = HostController.Request.QueryString["tech"]; + var sb = HostController.Request.QueryString["sort"] ?? "due"; + return Html(BuildDashboardPage(svc.BuildDashboard(fp, fl, fs, ft, sb))); } private ActionResult UpdateTicket() { 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 currentUser = GetCurrentUser(); - DateTime? eta = null; - DateTime etaParsed; - 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); + try + { + var ds = GetDataStore(); var svc = new ServiceTrackerService(Database, ds); + int jobId; if (!int.TryParse(HostController.Request.Form["jobId"], out jobId)) return new HttpStatusCodeResult(400); + var src = HostController.Request.Form["source"] ?? "disco"; + DateTime? eta = null; DateTime ep; if (DateTime.TryParse(HostController.Request.Form["eta"], out ep)) eta = ep; + svc.UpdateTicket(jobId, src, HostController.Request.Form["priority"], HostController.Request.Form["location"], + HostController.Request.Form["tech"], eta, HostController.Request.Form["status"], + HostController.Request.Form["summary"], GetUser()); + return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Detail?id=" + jobId + "&src=" + src); + } + catch (Exception ex) { return Err("Save error: " + ex.Message); } } private ActionResult AddNote() { 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 currentUser = GetCurrentUser(); - service.AddNote(jobId, currentUser, currentUser, - HostController.Request.Form["note"], HostController.Request.Form["noteType"] ?? "general"); - return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Detail?id=" + jobId); + try + { + var ds = GetDataStore(); var svc = new ServiceTrackerService(Database, ds); + int jobId; if (!int.TryParse(HostController.Request.Form["jobId"], out jobId)) return new HttpStatusCodeResult(400); + var src = HostController.Request.Form["source"] ?? "disco"; + var user = GetUser(); + svc.AddNote(jobId, src, user, svc.ResolveTechName(user) ?? user, + HostController.Request.Form["note"], HostController.Request.Form["noteType"] ?? "general"); + return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Detail?id=" + jobId + "&src=" + src); + } + catch (Exception ex) { return Err("Note error: " + ex.Message); } } private ActionResult TicketDetail() { - int jobId; - 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") - .FirstOrDefault(j => j.Id == jobId); - if (job == null) return new HttpNotFoundResult(); - var ticket = service.GetTicketDetail(jobId); - return HtmlResult(BuildDetailPage(job, ticket, config)); + 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 config = 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, config, src, jobId)); } private ActionResult ExportCsv() { - var dataStore = GetDataStore(); - var service = new ServiceTrackerService(Database, dataStore); - var model = service.BuildDashboard(); + var ds = GetDataStore(); var svc = new ServiceTrackerService(Database, ds); + var m = svc.BuildDashboard(); var sb = new StringBuilder(); - sb.AppendLine("JobId,Device,User,Priority,Location,Status,AssignedTech,OpenedDate,ETA,SlaDeadline,SlaBreached,AgeDays,Summary,NoteCount"); - foreach (var t in model.Tiles) + sb.AppendLine("Id,Source,Device,User,Priority,Location,Status,Tech,Opened,ETA,SLA,Breached,Age,Summary,Notes"); + foreach (var t in m.Tiles) { - 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(",", 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.SlaDeadline.HasValue ? t.SlaDeadline.Value.ToString("yyyy-MM-dd HH:mm") : "", + t.IsSlaBreached, t.AgeDays, Q(t.Summary), t.NoteCount)); } - HostController.Response.Headers.Add("Content-Disposition", "attachment; filename=\"ServiceTracker_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv\""); + 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() { - var dataStore = GetDataStore(); - var config = dataStore.LoadConfig(); - var saved = HostController.Request.QueryString["saved"] == "1"; - return HtmlResult(BuildConfigPage(config, saved)); + var ds = GetDataStore(); var config = ds.LoadConfig(); + return Html(BuildConfigPage(config, HostController.Request.QueryString["saved"] == "1", null)); } 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) + var ds = GetDataStore(); var config = ds.LoadConfig(); + try { - config.StatusOptions = statusRaw.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()).Where(s => s.Length > 0).ToList(); - } + int r; if (int.TryParse(F("refreshSeconds"), out r) && r >= 10) config.DashboardRefreshSeconds = r; + int di; if (int.TryParse(F("inactivitySeconds"), out di) && di >= 60) config.DetailInactivitySeconds = di; + config.AutoCreateTicketsForNewJobs = F("autoCreate") == "on"; + if (!string.IsNullOrEmpty(F("discoBaseUrl"))) config.DiscoBaseUrl = F("discoBaseUrl").TrimEnd('/'); + if (!string.IsNullOrEmpty(F("defaultPriority"))) config.DefaultPriorityId = F("defaultPriority"); + if (!string.IsNullOrEmpty(F("defaultLocation"))) config.DefaultLocationId = F("defaultLocation"); - // Priorities JSON - var priJson = HostController.Request.Form["prioritiesJson"]; - if (!string.IsNullOrEmpty(priJson)) + // Google Sheet + if (config.GoogleSheet == null) config.GoogleSheet = new GoogleSheetConfig(); + config.GoogleSheet.Enabled = F("gsEnabled") == "on"; + if (!string.IsNullOrEmpty(F("gsSpreadsheetId"))) config.GoogleSheet.SpreadsheetId = F("gsSpreadsheetId"); + if (!string.IsNullOrEmpty(F("gsGid"))) config.GoogleSheet.GId = F("gsGid"); + int gsr; if (int.TryParse(F("gsRefresh"), out gsr) && gsr >= 1) config.GoogleSheet.RefreshMinutes = gsr; + int c; if (int.TryParse(F("gsColTimestamp"), out c)) config.GoogleSheet.ColTimestamp = c; + if (int.TryParse(F("gsColEmail"), out c)) config.GoogleSheet.ColEmail = c; + if (int.TryParse(F("gsColDevice"), out c)) config.GoogleSheet.ColDeviceName = c; + if (int.TryParse(F("gsColLocation"), out c)) config.GoogleSheet.ColLocation = c; + if (int.TryParse(F("gsColIssue"), out c)) config.GoogleSheet.ColIssue = c; + if (int.TryParse(F("gsColPriority"), out c)) config.GoogleSheet.ColPriority = c; + if (int.TryParse(F("gsColStatus"), out c)) config.GoogleSheet.ColStatus = c; + if (int.TryParse(F("gsColAssigned"), out c)) config.GoogleSheet.ColAssignedTo = c; + int hr; if (int.TryParse(F("gsHeaderRows"), out hr)) config.GoogleSheet.HeaderRows = hr; + + // JSON lists + TryParseJson(F("techsJson"), ref config.Technicians); + TryParseJson(F("prioritiesJson"), ref config.Priorities); + TryParseJson(F("locationsJson"), ref config.Locations); + + var sr = F("statusOptions"); + if (sr != null) config.StatusOptions = sr.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).Where(s => s.Length > 0).ToList(); + + ds.SaveConfig(config); + return Html(BuildConfigPage(config, true, null)); + } + catch (Exception ex) { - try - { - var list = JsonConvert.DeserializeObject>(priJson); - if (list != null && list.Count > 0) config.Priorities = list; - } - catch { } + return Html(BuildConfigPage(config, false, "Save failed: " + ex.Message)); } - - // 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() + private ActionResult MarkCollected() { - if (HostController.HttpContext.User != null && HostController.HttpContext.User.Identity != null) - return HostController.HttpContext.User.Identity.Name ?? "system"; - return "system"; + if (HostController.Request.HttpMethod != "POST") return new HttpStatusCodeResult(405); + try + { + var ds = GetDataStore(); var svc = new ServiceTrackerService(Database, ds); + int jobId; if (!int.TryParse(HostController.Request.Form["jobId"], out jobId)) return new HttpStatusCodeResult(400); + var src = HostController.Request.Form["source"] ?? "disco"; + svc.MarkCollected(jobId, src, GetUser()); + return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Dashboard"); + } + catch (Exception ex) { return Err("Error: " + ex.Message); } } - private ActionResult HtmlResult(string html) { return new ContentResult { Content = html, ContentType = "text/html", ContentEncoding = Encoding.UTF8 }; } - private string Csv(string v) { return "\"" + (v ?? "").Replace("\"", "\"\"") + "\""; } + + // ===================== 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("

Service Tracker Error

" + H(msg) + "

Back to Dashboard

"); } + private string Q(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; } + private string F(string n) { return HostController.Request.Form[n]; } + private void TryParseJson(string json, ref List target) { if (!string.IsNullOrEmpty(json)) { try { var l = JsonConvert.DeserializeObject>(json); if (l != null && l.Count > 0) target = l; } catch { } } } - // ===================== DASHBOARD PAGE ===================== - - private string BuildDashboardPage(DashboardViewModel model) + // ===================== DASHBOARD ===================== + private string BuildDashboardPage(DashboardViewModel m) { var u = "/Plugin/Disco.Plugins.ServiceTracker"; - var sb = new StringBuilder(); - sb.Append("Service Tracker"); + var s = new StringBuilder(); + s.Append("Service Tracker"); + s.Append("

🛠 Service Tracker

Open: " + m.Stats.TotalOpen + ""); + if (m.Stats.FromGoogleSheet > 0) s.Append(" (incl. " + m.Stats.FromGoogleSheet + " from NTT sheet)"); + s.Append("
"); - // Header - sb.Append("
"); - sb.Append("

🛠 Service Tracker

"); - sb.Append("Open Jobs: " + model.Stats.TotalOpen + ""); - sb.Append("
"); - sb.Append("⚙ Config"); - sb.Append("⬇ Export"); - sb.Append("↻ Refresh"); - sb.Append("
"); - - // SLA Alerts - if (model.Stats.SlaBreached > 0) - sb.Append("
" + model.Stats.SlaBreached + " job(s) have BREACHED SLA — immediate attention required!
"); - if (model.Stats.SlaWarning > 0) - sb.Append("
" + model.Stats.SlaWarning + " job(s) approaching SLA deadline
"); + if (m.GoogleSheetError != null) s.Append("
📄 Sheet: " + H(m.GoogleSheetError) + "
"); + if (m.Stats.SlaBreached > 0) s.Append("
" + m.Stats.SlaBreached + " SLA BREACHED
"); + if (m.Stats.SlaWarning > 0) s.Append("
" + m.Stats.SlaWarning + " approaching SLA
"); // Stats - sb.Append("
"); - foreach (var p in model.Config.Priorities) + s.Append("
"); + foreach (var p in m.Config.Priorities) { int c; m.Stats.ByPriority.TryGetValue(p.Id, out c); s.Append("
" + c + "
" + H(p.Name) + "
"); } + s.Append("
" + m.Stats.SlaBreached + "
Breached
"); + s.Append("
" + m.Stats.AvgAgeDays.ToString("0.0") + "
Avg Age
"); + + // Locations + s.Append("
"); + foreach (var l in m.Config.Locations) { int c; m.Stats.ByLocation.TryGetValue(l.Id, out c); if (c > 0) s.Append("" + l.Icon + " " + H(l.Name) + " " + c + ""); } + s.Append("
"); + + // Sort + s.Append("
Sort:"); + foreach (var o in new[] { "due|Due", "priority|Priority", "age|Age", "sla|SLA", "modified|Updated" }) + { var p = o.Split('|'); s.Append("" + p[1] + ""); } + if (!string.IsNullOrEmpty(m.CurrentFilter)) s.Append("✖ Clear"); + s.Append("
"); + + // Main layout: tiles + ready sidebar + s.Append("
"); + + // Tile grid + s.Append("
"); + if (m.Tiles.Count == 0) s.Append("
No open jobs
"); + else foreach (var t in m.Tiles) s.Append(TileHtml(t, u, m.Config)); + s.Append("
"); + + // Ready for Return sidebar + s.Append("

📦 Ready for Return

"); + if (m.ReadyForReturn.Count == 0) s.Append("

None right now

"); + else foreach (var r in m.ReadyForReturn) { - int c; model.Stats.ByPriority.TryGetValue(p.Id, out c); - sb.Append("
" + c + "
" + H(p.Name) + "
"); + s.Append("
" + H(r.DisplayId) + "
"); + s.Append("
" + H(r.DeviceComputerName ?? r.DeviceSerialNumber) + "
"); + s.Append("
" + H(r.UserDisplayName) + "
"); + s.Append("
"); + s.Append(""); + s.Append("
"); } - sb.Append("
" + model.Stats.SlaBreached + "
SLA Breached
"); - sb.Append("
" + model.Stats.AvgAgeDays.ToString("0.0") + "
Avg Age (days)
"); + s.Append("
"); - // Location chips - sb.Append("
"); - foreach (var loc in model.Config.Locations) + // Workload + if (m.Stats.ByTech.Count > 0) { - 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 - sb.Append("
Sort by:"); - foreach (var opt in new[] { "due|Due Date", "priority|Priority", "age|Age", "sla|SLA Status", "modified|Last Updated" }) - { - var p = opt.Split('|'); - sb.Append("" + p[1] + ""); - } - if (!string.IsNullOrEmpty(model.CurrentFilter)) - sb.Append("✖ Clear Filter"); - sb.Append("
"); - - // 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)"); - sb.Append(""); - } - sb.Append("
"); - - // Tiles - sb.Append("
"); - if (model.Tiles.Count == 0) - sb.Append("
No open jobs
"); - else - foreach (var tile in model.Tiles) sb.Append(BuildTileHtml(tile, u)); - sb.Append("
"); - - // Tech workload - if (model.Stats.ByTech.Count > 0) - { - sb.Append("

Tech Workload

"); - foreach (var kv in model.Stats.ByTech.OrderByDescending(x => x.Value)) - sb.Append("
" + H(kv.Key) + "" + kv.Value + "
"); - sb.Append("
"); + s.Append("

Tech Workload

"); + foreach (var kv in m.Stats.ByTech.OrderByDescending(x => x.Value)) s.Append("
" + H(kv.Key) + "" + kv.Value + "
"); + s.Append("
"); } // Refresh bar - var rs = model.Config.DashboardRefreshSeconds; - sb.Append("
Auto-refresh in " + rs + "s"); - sb.Append("
"); - sb.Append("
"); - sb.Append(""); - sb.Append(""); - sb.Append(""); - return sb.ToString(); + var rs = m.Config.DashboardRefreshSeconds; + s.Append("
Refresh in " + rs + "s
"); + s.Append("
Service Tracker v" + ServiceTrackerService.PluginVersion + "
"); + s.Append(""); + s.Append(""); + return s.ToString(); } - private string BuildTileHtml(DashboardTile t, string u) + private string TileHtml(DashboardTile t, string u, ServiceTrackerConfig cfg) { - var sb = new StringBuilder(); - 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(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(t.LatestNote)); - if (t.NoteCount > 1) sb.Append(" (+" + (t.NoteCount - 1) + " more)"); - sb.Append("
"); - } - sb.Append("
"); - return sb.ToString(); + var s = new StringBuilder(); + var bc = t.IsSlaBreached ? " tb" : t.IsSlaWarning ? " tw" : ""; + s.Append("
"); + s.Append("
"); + s.Append("
" + H(t.DisplayId) + "
" + t.AgeBadge + "
"); + if (t.IsSlaBreached) s.Append("
⚠ SLA BREACHED
"); + else if (t.IsSlaWarning) s.Append("
⏰ SLA Warning
"); + s.Append("
" + H(t.DeviceComputerName ?? t.DeviceSerialNumber) + "
"); + if (t.DeviceModelDescription != null) s.Append("
" + H(t.DeviceModelDescription) + "
"); + s.Append("
"); + s.Append("
👤" + H(t.UserDisplayName ?? "\u2014") + "
"); + s.Append("
" + t.LocationIcon + " " + H(t.LocationName) + "
"); + s.Append("
📋" + H(t.StatusOverride ?? t.DiscoStatus) + "
"); + if (!string.IsNullOrEmpty(t.AssignedTechName)) s.Append("
🔧" + H(t.AssignedTechName) + "
"); + s.Append("
📅ETA: " + H(t.EtaDisplay) + "
"); + if (!string.IsNullOrEmpty(t.Summary)) { var sum = t.Summary.Length > 60 ? t.Summary.Substring(0, 57) + "..." : t.Summary; s.Append("
" + H(sum) + "
"); } + if (!string.IsNullOrEmpty(t.LatestNote)) { s.Append("
💬 " + H(t.LatestNote)); if (t.NoteCount > 1) s.Append(" (+" + (t.NoteCount - 1) + ")"); s.Append("
"); } + s.Append("
" + H(t.PriorityName) + "" + H(t.Source == "ntt" ? "NTT Sheet" : t.JobTypeDescription) + "
"); + return s.ToString(); } // ===================== DETAIL PAGE ===================== - - private string BuildDetailPage(Disco.Models.Repository.Job job, ServiceTicket ticket, ServiceTrackerConfig config) + private string BuildDetailPage(Disco.Models.Repository.Job job, ServiceTicket ticket, ServiceTrackerConfig cfg, string src, int jobId) { var u = "/Plugin/Disco.Plugins.ServiceTracker"; - var sb = new StringBuilder(); - sb.Append("Job #" + job.Id + ""); - sb.Append(""); + var s = new StringBuilder(); + var displayId = src == "ntt" ? "NTT#" + jobId : "DIC#" + jobId; + s.Append("" + displayId + ""); - // Header with Disco link - sb.Append("
"); - sb.Append("← Dashboard"); - sb.Append("

Job #" + job.Id + "

"); - sb.Append("
"); - sb.Append("📂 Open in Disco"); - sb.Append("
"); + s.Append("
← Dashboard

" + H(displayId) + "

"); + if (src == "disco") s.Append("📂 Open in Disco"); + s.Append("
"); - sb.Append("
"); + s.Append("
"); - // 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(""); - 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 ? " \u2014 " + H(job.DeviceHeldLocation) : "") + "
"); + // Left - Info + Edit + s.Append("
"); + s.Append("

Details

"); + if (job != null) + { + var domId = job.Device != null ? job.Device.DeviceDomainId : null; + s.Append(""); + s.Append(""); + s.Append(""); + s.Append(""); + s.Append(""); + if (job.ExpectedClosedDate.HasValue) s.Append(""); + } + else + { + s.Append(""); + if (ticket != null) + { + s.Append(""); + s.Append(""); + } + } + s.Append("
Device" + H(job.DeviceSerialNumber) + (domId != null ? " (" + H(domId) + ")" : "") + "
Model" + H(job.Device != null && job.Device.DeviceModel != null ? job.Device.DeviceModel.Description : null) + "
User" + H(job.User != null ? job.User.DisplayName : job.UserId) + "
Type" + H(job.JobType != null ? job.JobType.Description : job.JobTypeId) + "
Opened" + job.OpenedDate.ToString("dd MMM yyyy HH:mm") + " by " + H(job.OpenedTechUser != null ? job.OpenedTechUser.DisplayName : job.OpenedTechUserId) + "
Expected Close" + job.ExpectedClosedDate.Value.ToString("dd MMM yyyy") + "
SourceNTT Google Sheet
Summary" + H(ticket.Summary) + "
Created" + ticket.CreatedDate.ToString("dd MMM yyyy HH:mm") + "
"); // Edit form - 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") : ""; + 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") : ""; - sb.Append("

Service Tracker Settings

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

Settings

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

Activity

"); + s.Append("
"); + s.Append(""); + s.Append(""); + s.Append("
"); + s.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)) + s.Append("
"); + foreach (var n 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 author = note.AuthorName != null ? note.AuthorName : note.AuthorId; - sb.Append("
"); - sb.Append("
" + H(author) + ""); - sb.Append("" + H(note.NoteType) + ""); - sb.Append("" + note.Timestamp.ToString("dd MMM HH:mm") + "
"); - sb.Append("
" + H(note.Content) + "
"); + var tc = "#337AB7"; + switch (n.NoteType) { case "escalation": tc = "#DC3545"; break; case "resolution": tc = "#28A745"; break; case "update": tc = "#FFC107"; break; } + s.Append("
"); + s.Append("
" + H(n.AuthorName ?? n.AuthorId) + "" + H(n.NoteType) + "" + n.Timestamp.ToString("dd MMM HH:mm") + "
"); + s.Append("
" + H(n.Content) + "
"); } - sb.Append("
"); + s.Append("
"); } - else sb.Append("

No notes yet.

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

No notes yet.

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

📝 Change History

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

📝 Changes

WhenWhoFieldFromTo
"); 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") + "
"); + s.Append("" + ch.Timestamp.ToString("dd MMM HH:mm") + "" + H(ch.UserId) + "" + H(ch.Field) + "" + H(ch.OldValue ?? "\u2014") + "" + H(ch.NewValue ?? "\u2014") + ""); + s.Append("
"); } - sb.Append("
"); // detail-right, detail-grid - sb.Append(""); - sb.Append(""); - return sb.ToString(); + s.Append("
"); // dr, dg + + // Inactivity reload + var inact = cfg.DetailInactivitySeconds; + s.Append("
Service Tracker v" + ServiceTrackerService.PluginVersion + "
"); + s.Append(""); + s.Append(""); + return s.ToString(); } - // ===================== CONFIG EDITOR PAGE ===================== - - private string BuildConfigPage(ServiceTrackerConfig config, bool saved) + // ===================== CONFIG PAGE ===================== + private string BuildConfigPage(ServiceTrackerConfig cfg, bool saved, string error) { var u = "/Plugin/Disco.Plugins.ServiceTracker"; - var sb = new StringBuilder(); - sb.Append("Service Tracker Config"); - sb.Append(""); + var s = new StringBuilder(); + s.Append("Config"); + s.Append("
← Dashboard

⚙ Configuration

v" + ServiceTrackerService.PluginVersion + "
"); - sb.Append("
"); - sb.Append("← Dashboard"); - sb.Append("

⚙ Configuration

"); - sb.Append("v" + ServiceTrackerService.PluginVersion + ""); - sb.Append("
"); + if (saved) s.Append("
✅ Saved!
"); + if (error != null) s.Append("
" + H(error) + "
"); - if (saved) sb.Append("
✅ Configuration saved successfully!
"); + s.Append("
"); - sb.Append(""); - sb.Append("
"); + // General + s.Append("

General

"); + s.Append("
"); + s.Append("
"); + s.Append("
"); + s.Append("
"); + s.Append("
"); + s.Append("
"); - // General Settings - sb.Append("

General Settings

"); - sb.Append("
"); - sb.Append("
"); - sb.Append("
"); - sb.Append("
"); - sb.Append("
"); + // Google Sheet + var gs = cfg.GoogleSheet ?? new GoogleSheetConfig(); + s.Append("

📄 Google Sheet (NTT)

"); + s.Append("
"); + s.Append("
"); + s.Append("
"); + s.Append("
"); + s.Append("
"); + s.Append("

Column indices (0-based):

"); + s.Append("
"); + s.Append("
"); + s.Append("
"); + s.Append("
"); + s.Append("
"); + s.Append("
"); + s.Append("
"); + s.Append("
"); + s.Append("
"); + s.Append("

Sheet must be published to web as CSV (File > Share > Publish to web).

"); - // 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("
"); + // Technicians + s.Append("

🔧 Technicians

"); + foreach (var t in cfg.Technicians) s.Append(TechRow(t)); + s.Append("
"); + s.Append("
"); + + // Priorities + s.Append("

🎯 Priorities

"); + s.Append(""); + s.Append("
"); + foreach (var p in cfg.Priorities.OrderBy(x => x.SortOrder)) s.Append("" + H(p.Name) + " (" + p.SlaHours + "h)"); + s.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("
"); + s.Append("

📍 Locations

"); + s.Append(""); + s.Append("
"); + foreach (var l in cfg.Locations) s.Append("" + l.Icon + " " + H(l.Name) + ""); + s.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("
"); + // Statuses + s.Append("

📋 Status Options

"); + s.Append("
"); - sb.Append("
"); // config-grid + s.Append("
"); + s.Append("
Cancel
"); + s.Append("
Service Tracker v" + ServiceTrackerService.PluginVersion + "
"); - sb.Append("
"); - sb.Append(""); - sb.Append("Cancel"); - sb.Append("
"); + // Config JS - tech list management + JSON validation + s.Append(@""); + s.Append(""); + return s.ToString(); + } - sb.Append(""); - - // JSON validation script - sb.Append(""); - sb.Append(""); - return sb.ToString(); + private string TechRow(TechEntry t) + { + var ids = t.DiscoUserIds != null ? string.Join(", ", t.DiscoUserIds) : ""; + return "
" + + "" + + "" + + "
"; } // ===================== CSS ===================== - - private string GetDashboardCSS() + private string CSS() { - return @" -*{box-sizing:border-box;margin:0;padding:0;} -body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;padding:0;} -.header{display:flex;justify-content:space-between;align-items:center;padding:16px 24px;background:#fff;border-bottom:2px solid #337AB7;box-shadow:0 1px 3px rgba(0,0,0,.08);} -.header-left{display:flex;align-items:center;gap:16px;} -.header h1{font-size:22px;margin:0;color:#333;} -.subtitle{color:#666;font-size:14px;} -.header-right{display:flex;gap:8px;} -.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;} -.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;} -.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;} -.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;} -.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;} -@keyframes pulse{0%,100%{border-color:#DC3545;}50%{border-color:#f8d7da;}} -.tile-priority{height:5px;width:100%;} -.tile-header{display:flex;justify-content:space-between;padding:10px 14px 4px;} -.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;} -.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;} -.tile-icon{width:18px;text-align:center;font-size:12px;} -.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-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;} -.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-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;} -.muted{color:#999;font-style:italic;} -"; + 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}.tv{flex:1} +.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 20px;color:#888;font-size:15px} +.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} +.wln{font-size:12px}.wlc{font-weight:700;font-size:15px;color:#337AB7} +.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:120px}.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 GetDetailCSS() + private string DCSS() { - return @" -.back-link{font-size:13px;color:#337AB7;text-decoration:none;} .back-link:hover{text-decoration:underline;} -.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;padding:20px 24px;} -@media(max-width:900px){.detail-grid{grid-template-columns:1fr;}} -.detail-left,.detail-right{display:flex;flex-direction:column;gap:16px;} -.detail-card{background:#fff;border-radius:8px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.08);} -.detail-card h3{font-size:15px;color:#333;margin-bottom:12px;border-bottom:1px solid #eee;padding-bottom:8px;} -.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-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;} -.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-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;} -"; + 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}.tlt{font-size:10px;text-transform:capitalize}.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 GetConfigCSS() + private string CCSS() { - 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;} -"; + 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}"; } } }