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