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:
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user