feat: add core ServiceTrackerService with dashboard builder
This commit is contained in:
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Core service that combines Disco Job data with ServiceTracker metadata
|
||||
/// to produce the dashboard view model.
|
||||
/// </summary>
|
||||
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<DashboardTile>();
|
||||
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<DashboardTile> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user