From 087f1aa523ea588c240bade1cface4a54feadc38 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 5 May 2026 15:05:34 +1000 Subject: [PATCH] feat: add full dashboard WebHandler with tile view, detail page, notes --- WebHandler/ServiceTrackerWebHandler.cs | 655 +++++++++++++++++++++++++ 1 file changed, 655 insertions(+) create mode 100644 WebHandler/ServiceTrackerWebHandler.cs diff --git a/WebHandler/ServiceTrackerWebHandler.cs b/WebHandler/ServiceTrackerWebHandler.cs new file mode 100644 index 0000000..3f2b664 --- /dev/null +++ b/WebHandler/ServiceTrackerWebHandler.cs @@ -0,0 +1,655 @@ +using Disco.Plugins.ServiceTracker.Models; +using Disco.Plugins.ServiceTracker.Services; +using Disco.Services.Plugins; +using System; +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() + { + var dataPath = PluginConfigurationHandler.GetPluginDataDirectory( + HostController.HttpContext.Application["Disco.Plugins.ServiceTracker"] as Plugin + ?? new ServiceTrackerPlugin()); + // Fallback: use AppData path + if (string.IsNullOrEmpty(dataPath)) + { + dataPath = System.IO.Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "App_Data", "Plugins", "Disco.Plugins.ServiceTracker"); + } + return new ServiceTrackerDataStore(dataPath); + } + + public override ActionResult ExecuteAction(string ActionName) + { + 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(); + } + } + + 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)); + } + + 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 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"]; + DateTime? eta = null; + DateTime etaParsed; + if (DateTime.TryParse(HostController.Request.Form["eta"], out etaParsed)) + eta = etaParsed; + + var currentUser = HostController.HttpContext.User?.Identity?.Name ?? "system"; + service.UpdateTicket(jobId, priorityId, locationId, techId, eta, status, summary, currentUser); + + return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Dashboard"); + } + + 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 content = HostController.Request.Form["note"]; + var noteType = HostController.Request.Form["noteType"] ?? "general"; + var currentUser = HostController.HttpContext.User?.Identity?.Name ?? "system"; + var userName = currentUser; // Could resolve display name from DB + + service.AddNote(jobId, currentUser, userName, content, noteType); + 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); + + var dataStore = GetDataStore(); + var service = new ServiceTrackerService(Database, dataStore); + var config = dataStore.LoadConfig(); + + // Get the job + 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)); + } + + private ActionResult ExportCsv() + { + var dataStore = GetDataStore(); + var service = new ServiceTrackerService(Database, dataStore); + var model = service.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(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"), + t.EstimatedCompletion?.ToString("yyyy-MM-dd") ?? "", + t.SlaDeadline?.ToString("yyyy-MM-dd HH:mm") ?? "", + 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 + "\""); + return new ContentResult + { + Content = sb.ToString(), + ContentType = "text/csv", + ContentEncoding = Encoding.UTF8 + }; + } + + // --- HTML Builders --- + + 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 BuildDashboardPage(DashboardViewModel model) + { + var pluginUrl = "/Plugin/Disco.Plugins.ServiceTracker"; + var sb = new StringBuilder(); + + // --- Head --- + sb.Append(""); + sb.Append("Service Tracker Dashboard"); + sb.Append(""); + + // --- Header --- + sb.Append("
"); + sb.Append("
"); + sb.Append("

🛠 Service Tracker

"); + sb.Append("Open Jobs: " + model.Stats.TotalOpen + ""); + sb.Append("
"); + sb.Append("
"); + sb.Append("⬇ Export CSV"); + sb.Append("↻ Refresh"); + sb.Append("
"); + + // --- SLA Alert Banner --- + if (model.Stats.SlaBreached > 0) + { + sb.Append("
"); + sb.Append("⚠ " + model.Stats.SlaBreached + " job(s) have BREACHED SLA — immediate attention required!"); + sb.Append("
"); + } + if (model.Stats.SlaWarning > 0) + { + sb.Append("
"); + sb.Append("⏰ " + model.Stats.SlaWarning + " job(s) approaching SLA deadline"); + sb.Append("
"); + } + + // --- Stats Bar --- + 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) + "
"); + sb.Append("
"); + } + sb.Append("
"); + sb.Append("
" + model.Stats.SlaBreached + "
"); + sb.Append("
SLA Breached
"); + sb.Append("
"); + sb.Append("
" + model.Stats.AvgAgeDays.ToString("0.0") + "
"); + sb.Append("
Avg Age (days)
"); + sb.Append("
"); + + // --- Location Summary --- + sb.Append("
"); + foreach (var loc in model.Config.Locations) + { + int count; + model.Stats.ByLocation.TryGetValue(loc.Id, out count); + if (count > 0) + { + sb.Append(""); + sb.Append(loc.Icon + " " + H(loc.Name) + " " + count + ""); + sb.Append(""); + } + } + sb.Append("
"); + + // --- Sort Controls --- + sb.Append("
"); + sb.Append("Sort by:"); + string[] sortOptions = { "due|Due Date", "priority|Priority", "age|Age", "sla|SLA Status", "modified|Last Updated" }; + foreach (var opt in sortOptions) + { + var parts = opt.Split('|'); + var active = parts[0] == model.SortBy ? " active" : ""; + sb.Append("" + parts[1] + ""); + } + if (!string.IsNullOrEmpty(model.CurrentFilter)) + { + sb.Append("✖ Clear Filter"); + } + sb.Append("
"); + + // --- Priority Legend --- + sb.Append("
"); + 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)"); + sb.Append(""); + } + sb.Append("
"); + + // --- Tile Grid --- + sb.Append("
"); + if (model.Tiles.Count == 0) + { + sb.Append("
"); + sb.Append("
"); + sb.Append("
No open jobs found
"); + sb.Append("
"); + } + else + { + foreach (var tile in model.Tiles) + { + sb.Append(BuildTileHtml(tile, pluginUrl)); + } + } + sb.Append("
"); + + // --- Tech Workload --- + if (model.Stats.ByTech.Count > 0) + { + sb.Append("
"); + sb.Append("

Tech Workload

"); + sb.Append("
"); + foreach (var kv in model.Stats.ByTech.OrderByDescending(x => x.Value)) + { + sb.Append("
"); + sb.Append("" + H(kv.Key) + ""); + sb.Append("" + kv.Value + ""); + sb.Append("
"); + } + sb.Append("
"); + } + + sb.Append(""); + sb.Append(""); + return sb.ToString(); + } + + private string BuildTileHtml(DashboardTile tile, string pluginUrl) + { + var sb = new StringBuilder(); + string borderClass = tile.IsSlaBreached ? "tile-breached" : tile.IsSlaWarning ? "tile-warning" : ""; + + sb.Append("
"); + + // Priority strip + sb.Append("
"); + + // Tile header + sb.Append("
"); + sb.Append("
#" + tile.JobId + "
"); + sb.Append("
" + tile.AgeBadge + "
"); + sb.Append("
"); + + // SLA badge + if (tile.IsSlaBreached) + { + sb.Append("
⚠ SLA BREACHED
"); + } + else if (tile.IsSlaWarning) + { + sb.Append("
⏰ SLA Warning
"); + } + + // Device info + sb.Append("
"); + sb.Append("
" + H(tile.DeviceComputerName ?? tile.DeviceSerialNumber) + "
"); + if (tile.DeviceModelDescription != null) + sb.Append("
" + H(tile.DeviceModelDescription) + "
"); + sb.Append("
"); + + // User + sb.Append("
"); + sb.Append("👤"); + sb.Append("" + H(tile.UserDisplayName ?? "—") + ""); + sb.Append("
"); + + // Location + sb.Append("
"); + sb.Append(""); + sb.Append(tile.LocationIcon + " " + H(tile.LocationName)); + sb.Append("
"); + + // Status + sb.Append("
"); + sb.Append("📋"); + sb.Append("" + H(tile.StatusOverride ?? tile.DiscoStatus) + ""); + sb.Append("
"); + + // Assigned tech + if (!string.IsNullOrEmpty(tile.AssignedTechName)) + { + sb.Append("
"); + sb.Append("🔧"); + sb.Append("" + H(tile.AssignedTechName) + ""); + sb.Append("
"); + } + + // ETA + sb.Append("
"); + sb.Append("📅"); + sb.Append("ETA: " + H(tile.EtaDisplay) + ""); + sb.Append("
"); + + // Summary / Latest note + if (!string.IsNullOrEmpty(tile.Summary)) + { + sb.Append("
" + H(tile.Summary) + "
"); + } + if (!string.IsNullOrEmpty(tile.LatestNote)) + { + sb.Append("
"); + sb.Append("💬 " + H(tile.LatestNote)); + if (tile.NoteCount > 1) + sb.Append(" (+" + (tile.NoteCount - 1) + " more)"); + sb.Append("
"); + } + + // Priority label + sb.Append(""); + + sb.Append("
"); + return sb.ToString(); + } + + private string BuildDetailPage(Disco.Models.Repository.Job job, ServiceTicket ticket, ServiceTrackerConfig config) + { + var pluginUrl = "/Plugin/Disco.Plugins.ServiceTracker"; + var sb = new StringBuilder(); + + sb.Append(""); + sb.Append("Job #" + job.Id + " - Service Tracker"); + sb.Append(""); + + // Header + sb.Append("
"); + sb.Append("
"); + sb.Append("← Dashboard"); + sb.Append("

Job #" + job.Id + "

"); + sb.Append("
"); + + // Two-column layout + sb.Append("
"); + + // Left column - Job info + edit form + sb.Append("
"); + sb.Append("
"); + sb.Append("

Job Details

"); + sb.Append(""); + 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) + (job.Device?.DeviceDomainId != null ? " (" + H(job.Device.DeviceDomainId) + ")" : "") + "
Model" + H(job.Device?.DeviceModel?.Description) + "
User" + H(job.User?.DisplayName ?? job.UserId) + "
Type" + H(job.JobType?.Description ?? job.JobTypeId) + "
Opened" + job.OpenedDate.ToString("dd MMM yyyy HH:mm") + " by " + H(job.OpenedTechUser?.DisplayName ?? job.OpenedTechUserId) + "
Expected Close" + job.ExpectedClosedDate.Value.ToString("dd MMM yyyy") + "
Device Held" + job.DeviceHeld.Value.ToString("dd MMM yyyy") + (job.DeviceHeldLocation != null ? " — " + H(job.DeviceHeldLocation) : "") + "
"); + + // Edit form + sb.Append("
"); + sb.Append("

Service Tracker Settings

"); + sb.Append("
"); + sb.Append(""); + + // Priority dropdown + sb.Append("
"); + + // Location dropdown + sb.Append("
"); + + // Status dropdown + sb.Append("
"); + + // Assigned tech + sb.Append("
"); + sb.Append("
"); + + // ETA + sb.Append("
"); + sb.Append("
"); + + // Summary + sb.Append("
"); + sb.Append("
"); + + sb.Append(""); + sb.Append("
"); + sb.Append("
"); // end detail-left + + // Right column - Notes/timeline + sb.Append("
"); + sb.Append("
"); + sb.Append("

Activity Log

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

No notes yet.

"); + } + + sb.Append("
"); // end detail-right + sb.Append("
"); // end detail-grid + + sb.Append(""); + return sb.ToString(); + } + + private string GetDashboardCSS() + { + 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-sm{padding:5px 10px;font-size:12px;} +.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;} +.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;position:relative;} +.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;} +.muted{color:#999;font-style:italic;} +"; + } + + private string GetDetailCSS() + { + 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;padding-left:0;} +.timeline-item{display:flex;gap:12px;padding:8px 0;position:relative;} +.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;} +"; + } + } +}