diff --git a/Services/ServiceTrackerService.cs b/Services/ServiceTrackerService.cs new file mode 100644 index 0000000..1b92936 --- /dev/null +++ b/Services/ServiceTrackerService.cs @@ -0,0 +1,376 @@ +using Disco.Data.Repository; +using Disco.Models.Repository; +using Disco.Plugins.ServiceTracker.Models; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Disco.Plugins.ServiceTracker.Services +{ + /// + /// Core service that combines Disco Job data with ServiceTracker metadata + /// to produce the dashboard view model. + /// + public class ServiceTrackerService + { + private readonly DiscoDataContext _database; + private readonly ServiceTrackerDataStore _dataStore; + private readonly ServiceTrackerConfig _config; + + public ServiceTrackerService(DiscoDataContext database, ServiceTrackerDataStore dataStore) + { + _database = database; + _dataStore = dataStore; + _config = dataStore.LoadConfig(); + } + + public DashboardViewModel BuildDashboard(string filterPriority = null, string filterLocation = null, + string filterStatus = null, string filterTech = null, string sortBy = "due") + { + // Get all open jobs from Disco + var openJobs = _database.Jobs + .Include("Device") + .Include("Device.DeviceModel") + .Include("User") + .Include("OpenedTechUser") + .Include("JobType") + .Include("JobSubTypes") + .Where(j => j.ClosedDate == null) + .ToList(); + + // Load all service tracker tickets + var allTickets = _dataStore.LoadAllTickets(); + var ticketLookup = allTickets.ToDictionary(t => t.JobId, t => t); + + // Auto-create tickets for jobs that don't have one (if enabled) + if (_config.AutoCreateTicketsForNewJobs) + { + foreach (var job in openJobs) + { + if (!ticketLookup.ContainsKey(job.Id)) + { + var newTicket = CreateDefaultTicket(job); + _dataStore.SaveTicket(newTicket); + ticketLookup[job.Id] = newTicket; + } + } + } + + // Build tiles + var tiles = new List(); + foreach (var job in openJobs) + { + ServiceTicket ticket; + ticketLookup.TryGetValue(job.Id, out ticket); + tiles.Add(BuildTile(job, ticket)); + } + + // Apply filters + if (!string.IsNullOrEmpty(filterPriority)) + tiles = tiles.Where(t => t.PriorityId == filterPriority).ToList(); + if (!string.IsNullOrEmpty(filterLocation)) + tiles = tiles.Where(t => t.LocationId == filterLocation).ToList(); + if (!string.IsNullOrEmpty(filterStatus)) + tiles = tiles.Where(t => t.StatusOverride == filterStatus).ToList(); + if (!string.IsNullOrEmpty(filterTech)) + tiles = tiles.Where(t => t.AssignedTechId == filterTech).ToList(); + + // Sort + switch (sortBy) + { + case "priority": + tiles = tiles.OrderBy(t => t.PrioritySortOrder).ThenBy(t => t.SortDate).ToList(); + break; + case "age": + tiles = tiles.OrderByDescending(t => t.AgeDays).ToList(); + break; + case "modified": + tiles = tiles.OrderByDescending(t => t.LastModifiedDate).ToList(); + break; + case "sla": + tiles = tiles.OrderBy(t => t.IsSlaBreached ? 0 : t.IsSlaWarning ? 1 : 2) + .ThenBy(t => t.SlaDeadline ?? DateTime.MaxValue).ToList(); + break; + case "due": + default: + tiles = tiles.OrderBy(t => t.SortDate).ToList(); + break; + } + + // Build stats + var stats = BuildStats(tiles); + + return new DashboardViewModel + { + Tiles = tiles, + Stats = stats, + Config = _config, + CurrentFilter = filterPriority ?? filterLocation ?? filterStatus ?? "", + SortBy = sortBy + }; + } + + private DashboardTile BuildTile(Job job, ServiceTicket ticket) + { + var now = DateTime.Now; + + // Resolve priority + var priorityId = ticket?.PriorityId ?? _config.DefaultPriorityId; + var priority = _config.Priorities.FirstOrDefault(p => p.Id == priorityId) + ?? _config.Priorities.FirstOrDefault(); + + // Resolve location + var locationId = ticket?.LocationId ?? _config.DefaultLocationId; + var location = _config.Locations.FirstOrDefault(l => l.Id == locationId) + ?? _config.Locations.FirstOrDefault(); + + // Compute SLA + var slaDeadline = ticket?.SlaDeadline; + if (!slaDeadline.HasValue && priority != null && priority.SlaHours > 0) + { + slaDeadline = job.OpenedDate.AddHours(priority.SlaHours); + } + bool slaBreached = slaDeadline.HasValue && now > slaDeadline.Value; + bool slaWarning = !slaBreached && slaDeadline.HasValue && + priority != null && priority.SlaHours > 0 && + now > slaDeadline.Value.AddHours(-priority.SlaHours * 0.25); + + // Compute age + int ageDays = (int)(now - job.OpenedDate).TotalDays; + string ageBadge; + if (ageDays == 0) ageBadge = "Today"; + else if (ageDays == 1) ageBadge = "1 day"; + else if (ageDays < 7) ageBadge = ageDays + " days"; + else if (ageDays < 30) ageBadge = (ageDays / 7) + " wk" + (ageDays / 7 > 1 ? "s" : ""); + else ageBadge = (ageDays / 30) + " mo" + (ageDays / 30 > 1 ? "s" : ""); + + // ETA display + var eta = ticket?.EstimatedCompletion ?? job.ExpectedClosedDate; + string etaDisplay = "—"; + if (eta.HasValue) + { + var etaDays = (int)(eta.Value - now).TotalDays; + if (etaDays < 0) etaDisplay = Math.Abs(etaDays) + "d overdue"; + else if (etaDays == 0) etaDisplay = "Today"; + else if (etaDays == 1) etaDisplay = "Tomorrow"; + else etaDisplay = eta.Value.ToString("dd MMM"); + } + + // Sort date: use SLA deadline if breached, then ETA, then expected close, then opened + DateTime sortDate = slaBreached && slaDeadline.HasValue ? slaDeadline.Value + : eta ?? job.ExpectedClosedDate ?? job.OpenedDate; + + // Determine Disco status string + string discoStatus = "Open"; + if (job.WaitingForUserAction.HasValue) discoStatus = "Awaiting User Action"; + else if (job.DeviceHeld.HasValue && !job.DeviceReadyForReturn.HasValue) discoStatus = "Device Held"; + else if (job.DeviceReadyForReturn.HasValue && !job.DeviceReturnedDate.HasValue) discoStatus = "Ready for Return"; + + // Latest note + string latestNote = null; + int noteCount = 0; + if (ticket?.Notes != null && ticket.Notes.Count > 0) + { + noteCount = ticket.Notes.Count; + var latest = ticket.Notes.OrderByDescending(n => n.Timestamp).First(); + latestNote = latest.Content; + if (latestNote != null && latestNote.Length > 80) + latestNote = latestNote.Substring(0, 77) + "..."; + } + + return new DashboardTile + { + JobId = job.Id, + JobTypeDescription = job.JobType != null ? job.JobType.Description : job.JobTypeId, + DeviceSerialNumber = job.DeviceSerialNumber ?? "—", + DeviceModelDescription = job.Device?.DeviceModel != null ? job.Device.DeviceModel.Description : null, + DeviceComputerName = job.Device?.DeviceDomainId, + UserId = job.UserId, + UserDisplayName = job.User != null ? job.User.DisplayName : job.UserId, + OpenedByTechId = job.OpenedTechUserId, + OpenedByTechName = job.OpenedTechUser != null ? job.OpenedTechUser.DisplayName : job.OpenedTechUserId, + OpenedDate = job.OpenedDate, + ExpectedClosedDate = job.ExpectedClosedDate, + DiscoStatus = discoStatus, + PriorityId = priorityId, + PriorityName = priority?.Name ?? "Unknown", + PriorityColor = priority?.Color ?? "#999", + PrioritySortOrder = priority?.SortOrder ?? 99, + LocationId = locationId, + LocationName = location?.Name ?? "Unknown", + LocationIcon = location?.Icon ?? "", + LocationColor = location?.Color ?? "#999", + AssignedTechId = ticket?.AssignedTechId, + AssignedTechName = ResolveUserName(ticket?.AssignedTechId), + EstimatedCompletion = eta, + SlaDeadline = slaDeadline, + StatusOverride = ticket?.StatusOverride ?? discoStatus, + Summary = ticket?.Summary, + NoteCount = noteCount, + LatestNote = latestNote, + LastModifiedDate = ticket?.LastModifiedDate ?? job.OpenedDate, + IsSlaBreached = slaBreached, + IsSlaWarning = slaWarning, + AgeBadge = ageBadge, + AgeDays = ageDays, + EtaDisplay = etaDisplay, + SortDate = sortDate + }; + } + + private DashboardStats BuildStats(List tiles) + { + var stats = new DashboardStats + { + TotalOpen = tiles.Count, + SlaBreached = tiles.Count(t => t.IsSlaBreached), + SlaWarning = tiles.Count(t => t.IsSlaWarning), + AvgAgeDays = tiles.Count > 0 ? Math.Round(tiles.Average(t => t.AgeDays), 1) : 0, + OldestJobDays = tiles.Count > 0 ? tiles.Max(t => t.AgeDays) : 0 + }; + + // By priority + foreach (var p in _config.Priorities) + { + stats.ByPriority[p.Id] = tiles.Count(t => t.PriorityId == p.Id); + } + stats.Critical = tiles.Count(t => t.PriorityId == "critical"); + stats.High = tiles.Count(t => t.PriorityId == "high"); + stats.Medium = tiles.Count(t => t.PriorityId == "medium"); + stats.Low = tiles.Count(t => t.PriorityId == "low"); + stats.Scheduled = tiles.Count(t => t.PriorityId == "scheduled"); + + // By location + foreach (var l in _config.Locations) + { + stats.ByLocation[l.Id] = tiles.Count(t => t.LocationId == l.Id); + } + stats.InItOffice = tiles.Count(t => t.LocationId == "it-office"); + stats.WithUser = tiles.Count(t => t.LocationId == "with-user"); + stats.AtRepairer = tiles.Count(t => t.LocationId == "at-repairer"); + + // By status + foreach (var tile in tiles) + { + var status = tile.StatusOverride ?? "Open"; + if (!stats.ByStatus.ContainsKey(status)) + stats.ByStatus[status] = 0; + stats.ByStatus[status]++; + } + + // By tech + foreach (var tile in tiles.Where(t => !string.IsNullOrEmpty(t.AssignedTechId))) + { + var tech = tile.AssignedTechName ?? tile.AssignedTechId; + if (!stats.ByTech.ContainsKey(tech)) + stats.ByTech[tech] = 0; + stats.ByTech[tech]++; + } + + return stats; + } + + private ServiceTicket CreateDefaultTicket(Job job) + { + var priority = _config.Priorities.FirstOrDefault(p => p.Id == _config.DefaultPriorityId); + DateTime? sla = null; + if (priority != null && priority.SlaHours > 0) + sla = job.OpenedDate.AddHours(priority.SlaHours); + + // Determine default location based on DeviceHeld + string locationId = _config.DefaultLocationId; + if (job.DeviceHeld.HasValue) + { + if (!string.IsNullOrEmpty(job.DeviceHeldLocation)) + { + // Try to match the Disco location to a configured location + var matchedLoc = _config.Locations.FirstOrDefault( + l => l.Name.Equals(job.DeviceHeldLocation, StringComparison.OrdinalIgnoreCase)); + if (matchedLoc != null) + locationId = matchedLoc.Id; + } + } + + return new ServiceTicket + { + JobId = job.Id, + PriorityId = _config.DefaultPriorityId, + LocationId = locationId, + AssignedTechId = job.OpenedTechUserId, + EstimatedCompletion = job.ExpectedClosedDate, + SlaDeadline = sla, + StatusOverride = null, + Summary = null, + CreatedDate = DateTime.Now, + LastModifiedDate = DateTime.Now + }; + } + + private string ResolveUserName(string userId) + { + if (string.IsNullOrEmpty(userId)) return null; + try + { + var user = _database.Users.FirstOrDefault(u => u.UserId == userId); + return user != null ? user.DisplayName : userId; + } + catch + { + return userId; + } + } + + // --- CRUD operations for tickets --- + + public void UpdateTicket(int jobId, string priorityId, string locationId, + string assignedTechId, DateTime? eta, string status, string summary, string modifiedBy) + { + var ticket = _dataStore.GetTicket(jobId); + if (ticket == null) + { + ticket = new ServiceTicket { JobId = jobId }; + } + + if (priorityId != null) ticket.PriorityId = priorityId; + if (locationId != null) ticket.LocationId = locationId; + if (assignedTechId != null) ticket.AssignedTechId = assignedTechId; + if (eta.HasValue) ticket.EstimatedCompletion = eta; + if (status != null) ticket.StatusOverride = status; + if (summary != null) ticket.Summary = summary; + ticket.LastModifiedBy = modifiedBy; + + // Recalculate SLA if priority changed + if (priorityId != null) + { + var priority = _config.Priorities.FirstOrDefault(p => p.Id == priorityId); + if (priority != null && priority.SlaHours > 0) + { + ticket.SlaDeadline = ticket.CreatedDate.AddHours(priority.SlaHours); + } + else + { + ticket.SlaDeadline = null; + } + } + + _dataStore.SaveTicket(ticket); + } + + public void AddNote(int jobId, string authorId, string authorName, string content, string noteType) + { + var note = new TicketNote + { + AuthorId = authorId, + AuthorName = authorName, + Content = content, + NoteType = noteType ?? "general" + }; + _dataStore.AddNote(jobId, note); + } + + public ServiceTicket GetTicketDetail(int jobId) + { + return _dataStore.GetTicket(jobId); + } + } +}