330 lines
16 KiB
C#
330 lines
16 KiB
C#
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
|
|
{
|
|
public class ServiceTrackerService
|
|
{
|
|
public const string PluginVersion = "1.1.0";
|
|
|
|
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")
|
|
{
|
|
var openJobs = _database.Jobs
|
|
.Include("Device").Include("Device.DeviceModel")
|
|
.Include("User").Include("OpenedTechUser")
|
|
.Include("JobType").Include("JobSubTypes")
|
|
.Where(j => j.ClosedDate == null)
|
|
.ToList();
|
|
|
|
var allTickets = _dataStore.LoadAllTickets();
|
|
var ticketLookup = allTickets.ToDictionary(t => t.JobId, t => t);
|
|
|
|
if (_config.AutoCreateTicketsForNewJobs)
|
|
{
|
|
foreach (var job in openJobs)
|
|
{
|
|
if (!ticketLookup.ContainsKey(job.Id))
|
|
{
|
|
var newTicket = CreateDefaultTicket(job);
|
|
_dataStore.SaveTicket(newTicket);
|
|
ticketLookup[job.Id] = newTicket;
|
|
}
|
|
}
|
|
}
|
|
|
|
var tiles = new List<DashboardTile>();
|
|
foreach (var job in openJobs)
|
|
{
|
|
ServiceTicket ticket;
|
|
ticketLookup.TryGetValue(job.Id, out ticket);
|
|
tiles.Add(BuildTile(job, ticket));
|
|
}
|
|
|
|
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();
|
|
|
|
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.HasValue ? t.SlaDeadline.Value : DateTime.MaxValue).ToList();
|
|
break;
|
|
case "due":
|
|
default:
|
|
tiles = tiles.OrderBy(t => t.SortDate).ToList();
|
|
break;
|
|
}
|
|
|
|
return new DashboardViewModel
|
|
{
|
|
Tiles = tiles,
|
|
Stats = BuildStats(tiles),
|
|
Config = _config,
|
|
CurrentFilter = filterPriority ?? filterLocation ?? filterStatus ?? "",
|
|
SortBy = sortBy
|
|
};
|
|
}
|
|
|
|
private DashboardTile BuildTile(Job job, ServiceTicket ticket)
|
|
{
|
|
var now = DateTime.Now;
|
|
var priorityId = (ticket != null ? ticket.PriorityId : null) ?? _config.DefaultPriorityId;
|
|
var priority = _config.Priorities.FirstOrDefault(p => p.Id == priorityId) ?? _config.Priorities.FirstOrDefault();
|
|
var locationId = (ticket != null ? ticket.LocationId : null) ?? _config.DefaultLocationId;
|
|
var location = _config.Locations.FirstOrDefault(l => l.Id == locationId) ?? _config.Locations.FirstOrDefault();
|
|
|
|
var slaDeadline = ticket != null ? ticket.SlaDeadline : (DateTime?)null;
|
|
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);
|
|
|
|
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" : "");
|
|
|
|
var eta = (ticket != null ? ticket.EstimatedCompletion : null) ?? job.ExpectedClosedDate;
|
|
string etaDisplay = "\u2014";
|
|
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");
|
|
}
|
|
|
|
DateTime sortDate = slaBreached && slaDeadline.HasValue ? slaDeadline.Value
|
|
: eta.HasValue ? eta.Value
|
|
: job.ExpectedClosedDate.HasValue ? job.ExpectedClosedDate.Value : job.OpenedDate;
|
|
|
|
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";
|
|
|
|
string latestNote = null;
|
|
int noteCount = 0;
|
|
if (ticket != null && 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 ?? "\u2014",
|
|
DeviceModelDescription = job.Device != null && job.Device.DeviceModel != null ? job.Device.DeviceModel.Description : null,
|
|
DeviceComputerName = job.Device != null ? job.Device.DeviceDomainId : null,
|
|
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 != null ? priority.Name : "Unknown",
|
|
PriorityColor = priority != null ? priority.Color : "#999",
|
|
PrioritySortOrder = priority != null ? priority.SortOrder : 99,
|
|
LocationId = locationId,
|
|
LocationName = location != null ? location.Name : "Unknown",
|
|
LocationIcon = location != null ? location.Icon : "",
|
|
LocationColor = location != null ? location.Color : "#999",
|
|
AssignedTechId = ticket != null ? ticket.AssignedTechId : null,
|
|
AssignedTechName = ResolveUserName(ticket != null ? ticket.AssignedTechId : null),
|
|
EstimatedCompletion = eta,
|
|
SlaDeadline = slaDeadline,
|
|
StatusOverride = ticket != null && ticket.StatusOverride != null ? ticket.StatusOverride : discoStatus,
|
|
Summary = ticket != null ? ticket.Summary : null,
|
|
NoteCount = noteCount,
|
|
LatestNote = latestNote,
|
|
LastModifiedDate = ticket != null ? 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 => (double)t.AgeDays), 1) : 0,
|
|
OldestJobDays = tiles.Count > 0 ? tiles.Max(t => t.AgeDays) : 0
|
|
};
|
|
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");
|
|
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");
|
|
foreach (var tile in tiles)
|
|
{
|
|
var status = tile.StatusOverride ?? "Open";
|
|
if (!stats.ByStatus.ContainsKey(status)) stats.ByStatus[status] = 0;
|
|
stats.ByStatus[status]++;
|
|
}
|
|
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);
|
|
string locationId = _config.DefaultLocationId;
|
|
if (job.DeviceHeld.HasValue && !string.IsNullOrEmpty(job.DeviceHeldLocation))
|
|
{
|
|
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, 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; }
|
|
}
|
|
|
|
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 (ticket.ChangeLog == null) ticket.ChangeLog = new List<ChangeEntry>();
|
|
|
|
// Track changes
|
|
if (priorityId != null && priorityId != ticket.PriorityId)
|
|
{
|
|
ticket.ChangeLog.Add(new ChangeEntry { UserId = modifiedBy, Field = "Priority", OldValue = ticket.PriorityId, NewValue = priorityId });
|
|
ticket.PriorityId = priorityId;
|
|
}
|
|
if (locationId != null && locationId != ticket.LocationId)
|
|
{
|
|
ticket.ChangeLog.Add(new ChangeEntry { UserId = modifiedBy, Field = "Location", OldValue = ticket.LocationId, NewValue = locationId });
|
|
ticket.LocationId = locationId;
|
|
}
|
|
if (assignedTechId != null && assignedTechId != ticket.AssignedTechId)
|
|
{
|
|
ticket.ChangeLog.Add(new ChangeEntry { UserId = modifiedBy, Field = "Assigned Tech", OldValue = ticket.AssignedTechId, NewValue = assignedTechId });
|
|
ticket.AssignedTechId = assignedTechId;
|
|
}
|
|
if (eta.HasValue)
|
|
{
|
|
var oldEta = ticket.EstimatedCompletion.HasValue ? ticket.EstimatedCompletion.Value.ToString("dd MMM yyyy") : "none";
|
|
var newEta = eta.Value.ToString("dd MMM yyyy");
|
|
if (oldEta != newEta)
|
|
{
|
|
ticket.ChangeLog.Add(new ChangeEntry { UserId = modifiedBy, Field = "ETA", OldValue = oldEta, NewValue = newEta });
|
|
ticket.EstimatedCompletion = eta;
|
|
}
|
|
}
|
|
if (status != null && status != ticket.StatusOverride)
|
|
{
|
|
ticket.ChangeLog.Add(new ChangeEntry { UserId = modifiedBy, Field = "Status", OldValue = ticket.StatusOverride ?? "(Disco default)", NewValue = string.IsNullOrEmpty(status) ? "(Disco default)" : status });
|
|
ticket.StatusOverride = status;
|
|
}
|
|
if (summary != null && summary != ticket.Summary)
|
|
{
|
|
ticket.ChangeLog.Add(new ChangeEntry { UserId = modifiedBy, Field = "Summary", OldValue = null, NewValue = "(updated)" });
|
|
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)
|
|
{
|
|
_dataStore.AddNote(jobId, new TicketNote
|
|
{
|
|
AuthorId = authorId, AuthorName = authorName,
|
|
Content = content, NoteType = noteType ?? "general"
|
|
});
|
|
}
|
|
|
|
public ServiceTicket GetTicketDetail(int jobId) { return _dataStore.GetTicket(jobId); }
|
|
}
|
|
}
|