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); } } }