Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions JobFlow.API/Controllers/AssignmentController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,5 +96,31 @@ public async Task<IActionResult> UpdateStatus(Guid id, [FromBody] UpdateAssignme
return Ok(result.Value);
}

// Update assignees for an assignment
[HttpPut("{id:guid}/assignees")]
public async Task<IActionResult> UpdateAssignees(Guid id, [FromBody] UpdateAssignmentAssigneesRequestDto dto)
{
var organizationId = HttpContext.GetOrganizationId();

var result = await _assignmentService.UpdateAssignmentAssigneesAsync(organizationId, id, dto);
if (result.IsFailure)
return BadRequest(result.Error);

return Ok(result.Value);
}

// Update assignment notes
[HttpPut("{id:guid}/notes")]
public async Task<IActionResult> UpdateNotes(Guid id, [FromBody] UpdateAssignmentNotesRequestDto dto)
{
var organizationId = HttpContext.GetOrganizationId();

var result = await _assignmentService.UpdateAssignmentNotesAsync(organizationId, id, dto);
if (result.IsFailure)
return BadRequest(result.Error);

return Ok(result.Value);
}


}
78 changes: 78 additions & 0 deletions JobFlow.API/Controllers/DispatchController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using JobFlow.API.Extensions;
using JobFlow.Business.Models.DTOs;
using JobFlow.Business.Services.ServiceInterfaces;
using Microsoft.AspNetCore.Mvc;

namespace JobFlow.API.Controllers;

[ApiController]
[Route("api/[controller]")]
public class DispatchController : ControllerBase
{
private readonly IAssignmentService _assignmentService;
private readonly IAssignmentGenerator _assignmentGenerator;
private readonly IEmployeeService _employeeService;
private readonly IJobService _jobService;

public DispatchController(
IAssignmentService assignmentService,
IAssignmentGenerator assignmentGenerator,
IEmployeeService employeeService,
IJobService jobService)
{
_assignmentService = assignmentService;
_assignmentGenerator = assignmentGenerator;
_employeeService = employeeService;
_jobService = jobService;
}

[HttpGet("board")]
public async Task<IActionResult> GetBoard([FromQuery] DateTime start, [FromQuery] DateTime end)
{
var organizationId = HttpContext.GetOrganizationId();

var startUtc = DateTime.SpecifyKind(start, DateTimeKind.Utc);
var endUtc = DateTime.SpecifyKind(end, DateTimeKind.Utc);

var gen = await _assignmentGenerator.EnsureAssignmentsExistAsync(organizationId, startUtc, endUtc);
if (gen.IsFailure)
return BadRequest(gen.Error);

var assignmentsResult = await _assignmentService.GetAssignmentsAsync(organizationId, startUtc, endUtc);
if (assignmentsResult.IsFailure)
return BadRequest(assignmentsResult.Error);

var employeesResult = await _employeeService.GetByOrganizationIdAsync(organizationId);
if (employeesResult.IsFailure)
return BadRequest(employeesResult.Error);

var jobsResult = await _jobService.GetJobsAsync(organizationId);
if (jobsResult.IsFailure)
return BadRequest(jobsResult.Error);

var unscheduledJobs = jobsResult.Value
.Where(job => job.HasAssignments == false)
.Select(job => new DispatchUnscheduledJobDto
{
JobId = job.Id ?? Guid.Empty,
JobTitle = job.Title,
ClientName = job.OrganizationClient != null
? $"{job.OrganizationClient.FirstName} {job.OrganizationClient.LastName}".Trim()
: null,
JobLifecycleStatus = job.LifecycleStatus,
Notes = job.Comments
})
.ToList();

var response = new DispatchBoardDto
{
RangeStart = startUtc,
RangeEnd = endUtc,
Assignments = assignmentsResult.Value,
Employees = employeesResult.Value,
UnscheduledJobs = unscheduledJobs
};

return Ok(response);
}
}
14 changes: 14 additions & 0 deletions JobFlow.API/Controllers/JobController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,5 +114,19 @@ public async Task<IActionResult> UpsertRecurrence(Guid jobId, [FromBody] JobRecu
return Ok(result.Value);
}

[HttpPut("{jobId:guid}/status")]
public async Task<IActionResult> UpdateStatus(Guid jobId, [FromBody] UpdateJobStatusRequestDto request)
{
var organizationId = HttpContext.GetOrganizationId();
if (organizationId == Guid.Empty)
return Unauthorized("Organization context missing.");

var result = await _jobService.UpdateJobStatusAsync(organizationId, jobId, request.Status);
if (result.IsFailure)
return BadRequest(result.Error);

return Ok(result.Value);
}


}
6 changes: 6 additions & 0 deletions JobFlow.Business/ModelErrors/AssignmentErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ public static class AssignmentErrors
"The assignment does not belong to the current organization."
);

public static readonly Error InvalidAssignee =
Error.Validation(
"Assignment.InvalidAssignee",
"One or more assignees are invalid for this organization."
);

// ─────────────────────────────────────────────
// Scheduling
// ─────────────────────────────────────────────
Expand Down
21 changes: 21 additions & 0 deletions JobFlow.Business/Models/DTOs/AssignmentDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public class AssignmentDto

public string? Notes { get; set; }

public JobLifecycleStatus JobLifecycleStatus { get; set; }
public List<AssignmentAssigneeDto> Assignees { get; set; } = new();

// Useful for UI calendar
public string? JobTitle { get; set; }
public Guid OrganizationClientId { get; set; }
Expand Down Expand Up @@ -59,4 +62,22 @@ public class UpdateAssignmentStatusRequestDto
public AssignmentStatus Status { get; set; }
public DateTimeOffset? ActualStart { get; set; }
public DateTimeOffset? ActualEnd { get; set; }
}

public class AssignmentAssigneeDto
{
public Guid EmployeeId { get; set; }
public string? EmployeeName { get; set; }
public bool IsLead { get; set; }
}

public class UpdateAssignmentAssigneesRequestDto
{
public List<Guid> EmployeeIds { get; set; } = new();
public Guid? LeadEmployeeId { get; set; }
}

public class UpdateAssignmentNotesRequestDto
{
public string? Notes { get; set; }
}
22 changes: 22 additions & 0 deletions JobFlow.Business/Models/DTOs/DispatchDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using JobFlow.Domain.Enums;

namespace JobFlow.Business.Models.DTOs;

public class DispatchBoardDto
{
public DateTimeOffset RangeStart { get; set; }
public DateTimeOffset RangeEnd { get; set; }

public List<EmployeeDto> Employees { get; set; } = new();
public List<AssignmentDto> Assignments { get; set; } = new();
public List<DispatchUnscheduledJobDto> UnscheduledJobs { get; set; } = new();
}

public class DispatchUnscheduledJobDto
{
public Guid JobId { get; set; }
public string? JobTitle { get; set; }
public string? ClientName { get; set; }
public JobLifecycleStatus JobLifecycleStatus { get; set; }
public string? Notes { get; set; }
}
5 changes: 5 additions & 0 deletions JobFlow.Business/Models/DTOs/JobDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,9 @@ public class JobDto

public IEnumerable<AssignmentDto>? Assignments { get; set; }
public bool HasAssignments => Assignments?.Any() == true;
}

public class UpdateJobStatusRequestDto
{
public JobLifecycleStatus Status { get; set; }
}
110 changes: 110 additions & 0 deletions JobFlow.Business/Services/AssignmentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using JobFlow.Business.Onboarding;
using JobFlow.Business.Services.ServiceInterfaces;
using JobFlow.Domain;
using JobFlow.Domain.Enums;
using JobFlow.Domain.Models;
using MapsterMapper;
using Microsoft.EntityFrameworkCore;
Expand All @@ -15,6 +16,8 @@ namespace JobFlow.Business.Services;
public class AssignmentService : IAssignmentService
{
private readonly IRepository<Assignment> _assignments;
private readonly IRepository<AssignmentAssignee> _assignmentAssignees;
private readonly IRepository<Employee> _employees;
private readonly IRepository<Job> _jobs;
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
Expand All @@ -29,6 +32,8 @@ public AssignmentService(
{
_unitOfWork = unitOfWork;
_assignments = unitOfWork.RepositoryOf<Assignment>();
_assignmentAssignees = unitOfWork.RepositoryOf<AssignmentAssignee>();
_employees = unitOfWork.RepositoryOf<Employee>();
_jobs = unitOfWork.RepositoryOf<Job>();

_mapper = mapper;
Expand Down Expand Up @@ -75,6 +80,8 @@ await _onboardingService.MarkStepCompleteAsync(
var created = await _assignments.Query()
.Include(a => a.Job)
.ThenInclude(j => j.OrganizationClient)
.Include(a => a.AssignmentAssignees)
.ThenInclude(assignee => assignee.Employee)
.FirstAsync(a => a.Id == assignment.Id);

return Result.Success(MapToDto(created));
Expand All @@ -88,6 +95,8 @@ public async Task<Result<AssignmentDto>> UpdateAssignmentScheduleAsync(
var assignment = await _assignments.Query()
.Include(a => a.Job)
.ThenInclude(j => j.OrganizationClient)
.Include(a => a.AssignmentAssignees)
.ThenInclude(assignee => assignee.Employee)
.FirstOrDefaultAsync(a =>
a.Id == assignmentId &&
a.Job.OrganizationClient.OrganizationId == organizationId);
Expand All @@ -113,6 +122,8 @@ public async Task<Result<AssignmentDto>> UpdateAssignmentStatusAsync(
var assignment = await _assignments.Query()
.Include(a => a.Job)
.ThenInclude(j => j.OrganizationClient)
.Include(a => a.AssignmentAssignees)
.ThenInclude(assignee => assignee.Employee)
.FirstOrDefaultAsync(a =>
a.Id == assignmentId &&
a.Job.OrganizationClient.OrganizationId == organizationId);
Expand All @@ -130,6 +141,90 @@ public async Task<Result<AssignmentDto>> UpdateAssignmentStatusAsync(
return Result.Success(MapToDto(assignment));
}

public async Task<Result<AssignmentDto>> UpdateAssignmentAssigneesAsync(
Guid organizationId,
Guid assignmentId,
UpdateAssignmentAssigneesRequestDto dto)
{
var assignment = await _assignments.Query()
.Include(a => a.Job)
.ThenInclude(j => j.OrganizationClient)
.Include(a => a.AssignmentAssignees)
.ThenInclude(assignee => assignee.Employee)
.FirstOrDefaultAsync(a =>
a.Id == assignmentId &&
a.Job.OrganizationClient.OrganizationId == organizationId);

if (assignment == null)
return Result.Failure<AssignmentDto>(AssignmentErrors.NotFound);

var requestedIds = (dto.EmployeeIds ?? new List<Guid>()).Distinct().ToList();
if (requestedIds.Any())
{
var validEmployeeIds = await _employees.Query()
.Where(e => e.OrganizationId == organizationId && requestedIds.Contains(e.Id))
.Select(e => e.Id)
.ToListAsync();

if (validEmployeeIds.Count != requestedIds.Count)
return Result.Failure<AssignmentDto>(AssignmentErrors.InvalidAssignee);

requestedIds = validEmployeeIds;
}

if (assignment.AssignmentAssignees.Any())
{
_assignmentAssignees.RemoveRange(assignment.AssignmentAssignees);
}

var newAssignees = requestedIds.Select(id => new AssignmentAssignee
{
AssignmentId = assignmentId,
EmployeeId = id,
IsLead = dto.LeadEmployeeId.HasValue && dto.LeadEmployeeId.Value == id
}).ToList();

if (newAssignees.Any())
{
_assignmentAssignees.AddRange(newAssignees);
}

await _unitOfWork.SaveChangesAsync();

var updated = await _assignments.Query()
.Include(a => a.Job)
.ThenInclude(j => j.OrganizationClient)
.Include(a => a.AssignmentAssignees)
.ThenInclude(assignee => assignee.Employee)
.FirstAsync(a => a.Id == assignmentId);

return Result.Success(MapToDto(updated));
}

public async Task<Result<AssignmentDto>> UpdateAssignmentNotesAsync(
Guid organizationId,
Guid assignmentId,
UpdateAssignmentNotesRequestDto dto)
{
var assignment = await _assignments.Query()
.Include(a => a.Job)
.ThenInclude(j => j.OrganizationClient)
.Include(a => a.AssignmentAssignees)
.ThenInclude(assignee => assignee.Employee)
.FirstOrDefaultAsync(a =>
a.Id == assignmentId &&
a.Job.OrganizationClient.OrganizationId == organizationId);

if (assignment == null)
return Result.Failure<AssignmentDto>(AssignmentErrors.NotFound);

assignment.Notes = dto.Notes?.Trim();
_assignments.Update(assignment);
await _unitOfWork.SaveChangesAsync();

return Result.Success(MapToDto(assignment));
}

public async Task<Result<List<AssignmentDto>>> GetAssignmentsAsync(
Guid organizationId,
DateTime start,
Expand All @@ -138,6 +233,8 @@ public async Task<Result<List<AssignmentDto>>> GetAssignmentsAsync(
var assignments = await _assignments.Query()
.Include(a => a.Job)
.ThenInclude(j => j.OrganizationClient)
.Include(a => a.AssignmentAssignees)
.ThenInclude(assignee => assignee.Employee)
.Where(a =>
a.Job.OrganizationClient.OrganizationId == organizationId &&
a.ScheduledStart < end &&
Expand All @@ -155,6 +252,8 @@ public async Task<Result<AssignmentDto>> GetAssignmentByIdAsync(
var assignment = await _assignments.Query()
.Include(a => a.Job)
.ThenInclude(j => j.OrganizationClient)
.Include(a => a.AssignmentAssignees)
.ThenInclude(assignee => assignee.Employee)
.FirstOrDefaultAsync(a =>
a.Id == assignmentId &&
a.Job.OrganizationClient.OrganizationId == organizationId);
Expand All @@ -175,6 +274,17 @@ private AssignmentDto MapToDto(Assignment assignment)
dto.ClientName = assignment.Job?.OrganizationClient != null
? $"{assignment.Job.OrganizationClient.FirstName} {assignment.Job.OrganizationClient.LastName}"
: null;
dto.JobLifecycleStatus = assignment.Job?.LifecycleStatus ?? JobLifecycleStatus.Draft;
dto.Assignees = assignment.AssignmentAssignees
.Select(assignee => new AssignmentAssigneeDto
{
EmployeeId = assignee.EmployeeId,
EmployeeName = assignee.Employee != null
? $"{assignee.Employee.FirstName} {assignee.Employee.LastName}".Trim()
: null,
IsLead = assignee.IsLead
})
.ToList();

return dto;
}
Expand Down
Loading
Loading