Replace status_id with in_progress boolean field

- Remove task_statuses lookup table and StatusID foreign key
- Add InProgress boolean field to Task model
- Add database migration (005_replace_status_with_in_progress)
- Update all handlers, services, and repositories
- Update admin frontend to display in_progress as checkbox/boolean
- Remove Task Statuses tab from admin lookups page
- Update tests to use InProgress instead of StatusID
- Task categorization now uses InProgress for kanban column assignment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-08 20:48:16 -06:00
parent cb250f108b
commit c5b0225422
43 changed files with 353 additions and 753 deletions

View File

@@ -118,7 +118,7 @@ type TaskFilters struct {
ResidenceID *uint `form:"residence_id"`
CategoryID *uint `form:"category_id"`
PriorityID *uint `form:"priority_id"`
StatusID *uint `form:"status_id"`
InProgress *bool `form:"in_progress"`
IsCancelled *bool `form:"is_cancelled"`
IsArchived *bool `form:"is_archived"`
}
@@ -132,8 +132,8 @@ type UpdateTaskRequest struct {
Description *string `json:"description"`
CategoryID *uint `json:"category_id"`
PriorityID *uint `json:"priority_id"`
StatusID *uint `json:"status_id"`
FrequencyID *uint `json:"frequency_id"`
InProgress *bool `json:"in_progress"`
DueDate *string `json:"due_date"`
NextDueDate *string `json:"next_due_date"`
EstimatedCost *float64 `json:"estimated_cost"`
@@ -265,18 +265,18 @@ type CreateResidenceRequest struct {
// CreateTaskRequest for creating a new task
type CreateTaskRequest struct {
ResidenceID uint `json:"residence_id" binding:"required"`
CreatedByID uint `json:"created_by_id" binding:"required"`
Title string `json:"title" binding:"required,max=200"`
Description string `json:"description"`
CategoryID *uint `json:"category_id"`
PriorityID *uint `json:"priority_id"`
StatusID *uint `json:"status_id"`
FrequencyID *uint `json:"frequency_id"`
AssignedToID *uint `json:"assigned_to_id"`
DueDate *string `json:"due_date"`
ResidenceID uint `json:"residence_id" binding:"required"`
CreatedByID uint `json:"created_by_id" binding:"required"`
Title string `json:"title" binding:"required,max=200"`
Description string `json:"description"`
CategoryID *uint `json:"category_id"`
PriorityID *uint `json:"priority_id"`
FrequencyID *uint `json:"frequency_id"`
InProgress bool `json:"in_progress"`
AssignedToID *uint `json:"assigned_to_id"`
DueDate *string `json:"due_date"`
EstimatedCost *float64 `json:"estimated_cost"`
ContractorID *uint `json:"contractor_id"`
ContractorID *uint `json:"contractor_id"`
}
// CreateContractorRequest for creating a new contractor

View File

@@ -126,10 +126,9 @@ type TaskResponse struct {
CategoryName *string `json:"category_name,omitempty"`
PriorityID *uint `json:"priority_id,omitempty"`
PriorityName *string `json:"priority_name,omitempty"`
StatusID *uint `json:"status_id,omitempty"`
StatusName *string `json:"status_name,omitempty"`
FrequencyID *uint `json:"frequency_id,omitempty"`
FrequencyName *string `json:"frequency_name,omitempty"`
InProgress bool `json:"in_progress"`
DueDate *string `json:"due_date,omitempty"`
NextDueDate *string `json:"next_due_date,omitempty"`
EstimatedCost *float64 `json:"estimated_cost,omitempty"`

View File

@@ -62,13 +62,6 @@ type TaskStats struct {
OnHold int64 `json:"on_hold"`
}
// TaskStatusCount holds a single status count
type TaskStatusCount struct {
StatusID uint `json:"status_id"`
StatusName string `json:"status_name"`
Count int64 `json:"count"`
}
// ContractorStats holds contractor-related statistics
type ContractorStats struct {
Total int64 `json:"total"`
@@ -123,14 +116,13 @@ func (h *AdminDashboardHandler) GetStats(c *gin.Context) {
h.db.Model(&models.Task{}).Scopes(scopes.ScopeCancelled).Count(&stats.Tasks.Cancelled)
h.db.Model(&models.Task{}).Where("created_at >= ?", thirtyDaysAgo).Count(&stats.Tasks.New30d)
// Task counts by status (using LEFT JOIN to handle tasks with no status)
// Note: These status counts use DB status names, not kanban categorization
// Task counts by in_progress flag
// Pending: active tasks that are not in progress and not completed
h.db.Model(&models.Task{}).
Scopes(scopes.ScopeActive).
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("LOWER(task_taskstatus.name) = ? OR task_taskstatus.id IS NULL", "pending").
Scopes(scopes.ScopeActive, scopes.ScopeNotInProgress, scopes.ScopeNotCompleted).
Count(&stats.Tasks.Pending)
// In Progress: active tasks with in_progress = true
h.db.Model(&models.Task{}).
Scopes(scopes.ScopeActive, scopes.ScopeInProgress).
Count(&stats.Tasks.InProgress)
@@ -141,11 +133,8 @@ func (h *AdminDashboardHandler) GetStats(c *gin.Context) {
Scopes(scopes.ScopeActive, scopes.ScopeCompleted).
Count(&stats.Tasks.Completed)
h.db.Model(&models.Task{}).
Scopes(scopes.ScopeActive).
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("LOWER(task_taskstatus.name) = ?", "on hold").
Count(&stats.Tasks.OnHold)
// OnHold: no longer used with in_progress boolean, set to 0
stats.Tasks.OnHold = 0
// Overdue: uses consistent logic from internal/task/scopes.ScopeOverdue
// Effective date (COALESCE(next_due_date, due_date)) < now, active, not completed

View File

@@ -70,29 +70,6 @@ func (h *AdminLookupHandler) refreshPrioritiesCache(ctx context.Context) {
h.invalidateSeededDataCache(ctx)
}
// refreshStatusesCache invalidates and refreshes the statuses cache
func (h *AdminLookupHandler) refreshStatusesCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
var statuses []models.TaskStatus
if err := h.db.Order("display_order ASC, name ASC").Find(&statuses).Error; err != nil {
log.Warn().Err(err).Msg("Failed to fetch statuses for cache refresh")
return
}
if err := cache.CacheStatuses(ctx, statuses); err != nil {
log.Warn().Err(err).Msg("Failed to cache statuses")
return
}
log.Debug().Int("count", len(statuses)).Msg("Refreshed statuses cache")
// Invalidate unified seeded data cache
h.invalidateSeededDataCache(ctx)
}
// refreshFrequenciesCache invalidates and refreshes the frequencies cache
func (h *AdminLookupHandler) refreshFrequenciesCache(ctx context.Context) {
cache := services.GetCache()
@@ -471,149 +448,6 @@ func (h *AdminLookupHandler) DeletePriority(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Priority deleted successfully"})
}
// ========== Task Statuses ==========
type TaskStatusResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
DisplayOrder int `json:"display_order"`
}
type CreateUpdateStatusRequest struct {
Name string `json:"name" binding:"required,max=20"`
Description string `json:"description"`
Color string `json:"color" binding:"max=7"`
DisplayOrder *int `json:"display_order"`
}
func (h *AdminLookupHandler) ListStatuses(c *gin.Context) {
var statuses []models.TaskStatus
if err := h.db.Order("display_order ASC, name ASC").Find(&statuses).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statuses"})
return
}
responses := make([]TaskStatusResponse, len(statuses))
for i, s := range statuses {
responses[i] = TaskStatusResponse{
ID: s.ID,
Name: s.Name,
Description: s.Description,
Color: s.Color,
DisplayOrder: s.DisplayOrder,
}
}
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
}
func (h *AdminLookupHandler) CreateStatus(c *gin.Context) {
var req CreateUpdateStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
status := models.TaskStatus{
Name: req.Name,
Description: req.Description,
Color: req.Color,
}
if req.DisplayOrder != nil {
status.DisplayOrder = *req.DisplayOrder
}
if err := h.db.Create(&status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create status"})
return
}
// Refresh cache after creating
h.refreshStatusesCache(c.Request.Context())
c.JSON(http.StatusCreated, TaskStatusResponse{
ID: status.ID,
Name: status.Name,
Description: status.Description,
Color: status.Color,
DisplayOrder: status.DisplayOrder,
})
}
func (h *AdminLookupHandler) UpdateStatus(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status ID"})
return
}
var status models.TaskStatus
if err := h.db.First(&status, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Status not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch status"})
return
}
var req CreateUpdateStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
status.Name = req.Name
status.Description = req.Description
status.Color = req.Color
if req.DisplayOrder != nil {
status.DisplayOrder = *req.DisplayOrder
}
if err := h.db.Save(&status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update status"})
return
}
// Refresh cache after updating
h.refreshStatusesCache(c.Request.Context())
c.JSON(http.StatusOK, TaskStatusResponse{
ID: status.ID,
Name: status.Name,
Description: status.Description,
Color: status.Color,
DisplayOrder: status.DisplayOrder,
})
}
func (h *AdminLookupHandler) DeleteStatus(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status ID"})
return
}
var count int64
h.db.Model(&models.Task{}).Where("status_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete status that is in use by tasks"})
return
}
if err := h.db.Delete(&models.TaskStatus{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete status"})
return
}
// Refresh cache after deleting
h.refreshStatusesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Status deleted successfully"})
}
// ========== Task Frequencies ==========
type TaskFrequencyResponse struct {

View File

@@ -143,16 +143,6 @@ func (h *AdminSettingsHandler) cacheAllLookups(ctx context.Context) (bool, error
}
log.Debug().Int("count", len(priorities)).Msg("Cached task priorities")
// Fetch and cache task statuses
var statuses []models.TaskStatus
if err := h.db.Order("display_order ASC, name ASC").Find(&statuses).Error; err != nil {
return false, fmt.Errorf("failed to fetch statuses: %w", err)
}
if err := cache.CacheStatuses(ctx, statuses); err != nil {
return false, fmt.Errorf("failed to cache statuses: %w", err)
}
log.Debug().Int("count", len(statuses)).Msg("Cached task statuses")
// Fetch and cache task frequencies
var frequencies []models.TaskFrequency
if err := h.db.Order("display_order ASC, name ASC").Find(&frequencies).Error; err != nil {
@@ -203,7 +193,6 @@ func (h *AdminSettingsHandler) cacheAllLookups(ctx context.Context) (bool, error
"task_categories": categories,
"task_priorities": priorities,
"task_frequencies": frequencies,
"task_statuses": statuses,
"contractor_specialties": specialties,
"task_templates": buildGroupedTemplates(taskTemplates),
}

View File

@@ -38,8 +38,7 @@ func (h *AdminTaskHandler) List(c *gin.Context) {
Preload("Residence").
Preload("CreatedBy").
Preload("Category").
Preload("Priority").
Preload("Status")
Preload("Priority")
// Apply search
if filters.Search != "" {
@@ -57,8 +56,8 @@ func (h *AdminTaskHandler) List(c *gin.Context) {
if filters.PriorityID != nil {
query = query.Where("priority_id = ?", *filters.PriorityID)
}
if filters.StatusID != nil {
query = query.Where("status_id = ?", *filters.StatusID)
if filters.InProgress != nil {
query = query.Where("in_progress = ?", *filters.InProgress)
}
if filters.IsCancelled != nil {
query = query.Where("is_cancelled = ?", *filters.IsCancelled)
@@ -109,7 +108,6 @@ func (h *AdminTaskHandler) Get(c *gin.Context) {
Preload("AssignedTo").
Preload("Category").
Preload("Priority").
Preload("Status").
Preload("Frequency").
Preload("Completions").
First(&task, id).Error; err != nil {
@@ -210,8 +208,8 @@ func (h *AdminTaskHandler) Update(c *gin.Context) {
if req.PriorityID != nil {
updates["priority_id"] = *req.PriorityID
}
if req.StatusID != nil {
updates["status_id"] = *req.StatusID
if req.InProgress != nil {
updates["in_progress"] = *req.InProgress
}
if req.FrequencyID != nil {
updates["frequency_id"] = *req.FrequencyID
@@ -254,7 +252,7 @@ func (h *AdminTaskHandler) Update(c *gin.Context) {
}
// Reload with preloads for response
h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").Preload("Status").First(&task, id)
h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").First(&task, id)
c.JSON(http.StatusOK, h.toTaskResponse(&task))
}
@@ -287,7 +285,7 @@ func (h *AdminTaskHandler) Create(c *gin.Context) {
Description: req.Description,
CategoryID: req.CategoryID,
PriorityID: req.PriorityID,
StatusID: req.StatusID,
InProgress: req.InProgress,
FrequencyID: req.FrequencyID,
AssignedToID: req.AssignedToID,
ContractorID: req.ContractorID,
@@ -311,7 +309,7 @@ func (h *AdminTaskHandler) Create(c *gin.Context) {
return
}
h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").Preload("Status").First(&task, task.ID)
h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").First(&task, task.ID)
c.JSON(http.StatusCreated, h.toTaskResponse(&task))
}
@@ -336,7 +334,7 @@ func (h *AdminTaskHandler) Delete(c *gin.Context) {
// Soft delete - archive and cancel
task.IsArchived = true
task.IsCancelled = true
if err := h.db.Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Status", "Frequency", "ParentTask", "Completions").Save(&task).Error; err != nil {
if err := h.db.Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Frequency", "ParentTask", "Completions").Save(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete task"})
return
}
@@ -373,7 +371,7 @@ func (h *AdminTaskHandler) toTaskResponse(task *models.Task) dto.TaskResponse {
Description: task.Description,
CategoryID: task.CategoryID,
PriorityID: task.PriorityID,
StatusID: task.StatusID,
InProgress: task.InProgress,
FrequencyID: task.FrequencyID,
ContractorID: task.ContractorID,
ParentTaskID: task.ParentTaskID,
@@ -401,9 +399,6 @@ func (h *AdminTaskHandler) toTaskResponse(task *models.Task) dto.TaskResponse {
if task.Priority != nil {
response.PriorityName = &task.Priority.Name
}
if task.Status != nil {
response.StatusName = &task.Status.Name
}
if task.Frequency != nil {
response.FrequencyName = &task.Frequency.Name
}

View File

@@ -263,15 +263,6 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
priorities.DELETE("/:id", lookupHandler.DeletePriority)
}
// Task Statuses
statuses := protected.Group("/lookups/statuses")
{
statuses.GET("", lookupHandler.ListStatuses)
statuses.POST("", lookupHandler.CreateStatus)
statuses.PUT("/:id", lookupHandler.UpdateStatus)
statuses.DELETE("/:id", lookupHandler.DeleteStatus)
}
// Task Frequencies
frequencies := protected.Group("/lookups/frequencies")
{