Fix backend API parity: document filters, task days param, i18n locales, contract tests
- Add document list filter support (residence, type, category, contractor, is_active, expiring_soon, search) to handler/service/repo - Add `days` query param parsing to ListTasks handler (matches ListTasksByResidence) - Add `error.invalid_token` i18n key to all 9 non-English locale files - Update contract test to include VerificationResponse mapping Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/treytartt/casera-api/internal/dto/requests"
|
"github.com/treytartt/casera-api/internal/dto/requests"
|
||||||
"github.com/treytartt/casera-api/internal/middleware"
|
"github.com/treytartt/casera-api/internal/middleware"
|
||||||
"github.com/treytartt/casera-api/internal/models"
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
"github.com/treytartt/casera-api/internal/repositories"
|
||||||
"github.com/treytartt/casera-api/internal/services"
|
"github.com/treytartt/casera-api/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,9 +35,45 @@ func NewDocumentHandler(documentService *services.DocumentService, storageServic
|
|||||||
// ListDocuments handles GET /api/documents/
|
// ListDocuments handles GET /api/documents/
|
||||||
func (h *DocumentHandler) ListDocuments(c echo.Context) error {
|
func (h *DocumentHandler) ListDocuments(c echo.Context) error {
|
||||||
user := c.Get(middleware.AuthUserKey).(*models.User)
|
user := c.Get(middleware.AuthUserKey).(*models.User)
|
||||||
response, err := h.documentService.ListDocuments(user.ID)
|
|
||||||
|
// Build filter from query params (matching KMP DocumentApi parameters)
|
||||||
|
var filter *repositories.DocumentFilter
|
||||||
|
if c.QueryParam("residence") != "" || c.QueryParam("document_type") != "" ||
|
||||||
|
c.QueryParam("category") != "" || c.QueryParam("contractor") != "" ||
|
||||||
|
c.QueryParam("is_active") != "" || c.QueryParam("expiring_soon") != "" ||
|
||||||
|
c.QueryParam("tags") != "" || c.QueryParam("search") != "" {
|
||||||
|
filter = &repositories.DocumentFilter{
|
||||||
|
DocumentType: c.QueryParam("document_type"),
|
||||||
|
Category: c.QueryParam("category"),
|
||||||
|
Tags: c.QueryParam("tags"),
|
||||||
|
Search: c.QueryParam("search"),
|
||||||
|
}
|
||||||
|
if rid := c.QueryParam("residence"); rid != "" {
|
||||||
|
if parsed, err := strconv.ParseUint(rid, 10, 32); err == nil {
|
||||||
|
residenceID := uint(parsed)
|
||||||
|
filter.ResidenceID = &residenceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cid := c.QueryParam("contractor"); cid != "" {
|
||||||
|
if parsed, err := strconv.ParseUint(cid, 10, 32); err == nil {
|
||||||
|
contractorID := uint(parsed)
|
||||||
|
filter.ContractorID = &contractorID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ia := c.QueryParam("is_active"); ia != "" {
|
||||||
|
isActive := ia == "true" || ia == "1"
|
||||||
|
filter.IsActive = &isActive
|
||||||
|
}
|
||||||
|
if es := c.QueryParam("expiring_soon"); es != "" {
|
||||||
|
if parsed, err := strconv.Atoi(es); err == nil {
|
||||||
|
filter.ExpiringSoon = &parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.documentService.ListDocuments(user.ID, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
|
return err
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, response)
|
return c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,19 @@ func (h *TaskHandler) ListTasks(c echo.Context) error {
|
|||||||
go h.taskService.UpdateUserTimezone(user.ID, tzHeader)
|
go h.taskService.UpdateUserTimezone(user.ID, tzHeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := h.taskService.ListTasks(user.ID, userNow)
|
daysThreshold := 30
|
||||||
|
// Support "days" param first, fall back to "days_threshold" for backward compatibility
|
||||||
|
if d := c.QueryParam("days"); d != "" {
|
||||||
|
if parsed, err := strconv.Atoi(d); err == nil {
|
||||||
|
daysThreshold = parsed
|
||||||
|
}
|
||||||
|
} else if d := c.QueryParam("days_threshold"); d != "" {
|
||||||
|
if parsed, err := strconv.Atoi(d); err == nil {
|
||||||
|
daysThreshold = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.taskService.ListTasks(user.ID, daysThreshold, userNow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"error.email_already_taken": "E-Mail bereits vergeben",
|
"error.email_already_taken": "E-Mail bereits vergeben",
|
||||||
"error.registration_failed": "Registrierung fehlgeschlagen",
|
"error.registration_failed": "Registrierung fehlgeschlagen",
|
||||||
"error.not_authenticated": "Nicht authentifiziert",
|
"error.not_authenticated": "Nicht authentifiziert",
|
||||||
|
"error.invalid_token": "Ungültiges Token",
|
||||||
"error.failed_to_get_user": "Benutzer konnte nicht abgerufen werden",
|
"error.failed_to_get_user": "Benutzer konnte nicht abgerufen werden",
|
||||||
"error.failed_to_update_profile": "Profil konnte nicht aktualisiert werden",
|
"error.failed_to_update_profile": "Profil konnte nicht aktualisiert werden",
|
||||||
"error.invalid_verification_code": "Ungultiger Verifizierungscode",
|
"error.invalid_verification_code": "Ungultiger Verifizierungscode",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"error.email_already_taken": "El correo electronico ya esta en uso",
|
"error.email_already_taken": "El correo electronico ya esta en uso",
|
||||||
"error.registration_failed": "Error en el registro",
|
"error.registration_failed": "Error en el registro",
|
||||||
"error.not_authenticated": "No autenticado",
|
"error.not_authenticated": "No autenticado",
|
||||||
|
"error.invalid_token": "Token no válido",
|
||||||
"error.failed_to_get_user": "Error al obtener el usuario",
|
"error.failed_to_get_user": "Error al obtener el usuario",
|
||||||
"error.failed_to_update_profile": "Error al actualizar el perfil",
|
"error.failed_to_update_profile": "Error al actualizar el perfil",
|
||||||
"error.invalid_verification_code": "Codigo de verificacion no valido",
|
"error.invalid_verification_code": "Codigo de verificacion no valido",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"error.email_already_taken": "Email deja utilise",
|
"error.email_already_taken": "Email deja utilise",
|
||||||
"error.registration_failed": "Echec de l'inscription",
|
"error.registration_failed": "Echec de l'inscription",
|
||||||
"error.not_authenticated": "Non authentifie",
|
"error.not_authenticated": "Non authentifie",
|
||||||
|
"error.invalid_token": "Jeton non valide",
|
||||||
"error.failed_to_get_user": "Echec de la recuperation de l'utilisateur",
|
"error.failed_to_get_user": "Echec de la recuperation de l'utilisateur",
|
||||||
"error.failed_to_update_profile": "Echec de la mise a jour du profil",
|
"error.failed_to_update_profile": "Echec de la mise a jour du profil",
|
||||||
"error.invalid_verification_code": "Code de verification non valide",
|
"error.invalid_verification_code": "Code de verification non valide",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"error.email_already_taken": "Email già in uso",
|
"error.email_already_taken": "Email già in uso",
|
||||||
"error.registration_failed": "Registrazione fallita",
|
"error.registration_failed": "Registrazione fallita",
|
||||||
"error.not_authenticated": "Non autenticato",
|
"error.not_authenticated": "Non autenticato",
|
||||||
|
"error.invalid_token": "Token non valido",
|
||||||
"error.failed_to_get_user": "Impossibile recuperare l'utente",
|
"error.failed_to_get_user": "Impossibile recuperare l'utente",
|
||||||
"error.failed_to_update_profile": "Impossibile aggiornare il profilo",
|
"error.failed_to_update_profile": "Impossibile aggiornare il profilo",
|
||||||
"error.invalid_verification_code": "Codice di verifica non valido",
|
"error.invalid_verification_code": "Codice di verifica non valido",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"error.email_already_taken": "このメールアドレスは既に使用されています",
|
"error.email_already_taken": "このメールアドレスは既に使用されています",
|
||||||
"error.registration_failed": "登録に失敗しました",
|
"error.registration_failed": "登録に失敗しました",
|
||||||
"error.not_authenticated": "認証されていません",
|
"error.not_authenticated": "認証されていません",
|
||||||
|
"error.invalid_token": "無効なトークンです",
|
||||||
"error.failed_to_get_user": "ユーザー情報の取得に失敗しました",
|
"error.failed_to_get_user": "ユーザー情報の取得に失敗しました",
|
||||||
"error.failed_to_update_profile": "プロフィールの更新に失敗しました",
|
"error.failed_to_update_profile": "プロフィールの更新に失敗しました",
|
||||||
"error.invalid_verification_code": "無効な認証コードです",
|
"error.invalid_verification_code": "無効な認証コードです",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"error.email_already_taken": "이미 사용 중인 이메일입니다",
|
"error.email_already_taken": "이미 사용 중인 이메일입니다",
|
||||||
"error.registration_failed": "회원가입에 실패했습니다",
|
"error.registration_failed": "회원가입에 실패했습니다",
|
||||||
"error.not_authenticated": "인증되지 않았습니다",
|
"error.not_authenticated": "인증되지 않았습니다",
|
||||||
|
"error.invalid_token": "유효하지 않은 토큰입니다",
|
||||||
"error.failed_to_get_user": "사용자 정보를 가져오는데 실패했습니다",
|
"error.failed_to_get_user": "사용자 정보를 가져오는데 실패했습니다",
|
||||||
"error.failed_to_update_profile": "프로필 업데이트에 실패했습니다",
|
"error.failed_to_update_profile": "프로필 업데이트에 실패했습니다",
|
||||||
"error.invalid_verification_code": "유효하지 않은 인증 코드입니다",
|
"error.invalid_verification_code": "유효하지 않은 인증 코드입니다",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"error.email_already_taken": "E-mailadres is al in gebruik",
|
"error.email_already_taken": "E-mailadres is al in gebruik",
|
||||||
"error.registration_failed": "Registratie mislukt",
|
"error.registration_failed": "Registratie mislukt",
|
||||||
"error.not_authenticated": "Niet geauthenticeerd",
|
"error.not_authenticated": "Niet geauthenticeerd",
|
||||||
|
"error.invalid_token": "Ongeldig token",
|
||||||
"error.failed_to_get_user": "Gebruiker ophalen mislukt",
|
"error.failed_to_get_user": "Gebruiker ophalen mislukt",
|
||||||
"error.failed_to_update_profile": "Profiel bijwerken mislukt",
|
"error.failed_to_update_profile": "Profiel bijwerken mislukt",
|
||||||
"error.invalid_verification_code": "Ongeldige verificatiecode",
|
"error.invalid_verification_code": "Ongeldige verificatiecode",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"error.email_already_taken": "Email ja em uso",
|
"error.email_already_taken": "Email ja em uso",
|
||||||
"error.registration_failed": "Falha no registro",
|
"error.registration_failed": "Falha no registro",
|
||||||
"error.not_authenticated": "Nao autenticado",
|
"error.not_authenticated": "Nao autenticado",
|
||||||
|
"error.invalid_token": "Token inválido",
|
||||||
"error.failed_to_get_user": "Falha ao obter usuario",
|
"error.failed_to_get_user": "Falha ao obter usuario",
|
||||||
"error.failed_to_update_profile": "Falha ao atualizar perfil",
|
"error.failed_to_update_profile": "Falha ao atualizar perfil",
|
||||||
"error.invalid_verification_code": "Codigo de verificacao invalido",
|
"error.invalid_verification_code": "Codigo de verificacao invalido",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"error.email_already_taken": "邮箱已被占用",
|
"error.email_already_taken": "邮箱已被占用",
|
||||||
"error.registration_failed": "注册失败",
|
"error.registration_failed": "注册失败",
|
||||||
"error.not_authenticated": "未认证",
|
"error.not_authenticated": "未认证",
|
||||||
|
"error.invalid_token": "无效的令牌",
|
||||||
"error.failed_to_get_user": "获取用户信息失败",
|
"error.failed_to_get_user": "获取用户信息失败",
|
||||||
"error.failed_to_update_profile": "更新个人资料失败",
|
"error.failed_to_update_profile": "更新个人资料失败",
|
||||||
"error.invalid_verification_code": "验证码无效",
|
"error.invalid_verification_code": "验证码无效",
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ var excludedSchemas = map[string]string{
|
|||||||
"SharePackageResponse": "Used for .casera file sharing, handled by SharedContractor",
|
"SharePackageResponse": "Used for .casera file sharing, handled by SharedContractor",
|
||||||
"UnregisterDeviceRequest": "Simple oneoff request",
|
"UnregisterDeviceRequest": "Simple oneoff request",
|
||||||
"UpdateTaskCompletionRequest": "Not yet used in KMP",
|
"UpdateTaskCompletionRequest": "Not yet used in KMP",
|
||||||
"SubscriptionResponse": "Different shape — SubscriptionStatusResponse is mapped",
|
"SubscriptionResponse": "Inline in purchase/restore handler — KMP maps via VerificationResponse.subscription",
|
||||||
"UpgradeTriggerResponse": "Shape differs from KMP UpgradeTriggerData",
|
"UpgradeTriggerResponse": "Shape differs from KMP UpgradeTriggerData",
|
||||||
"UploadResult": "Handled inline in upload response parsing",
|
"UploadResult": "Handled inline in upload response parsing",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ import (
|
|||||||
"github.com/treytartt/casera-api/internal/models"
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DocumentFilter contains optional filter parameters for listing documents.
|
||||||
|
type DocumentFilter struct {
|
||||||
|
ResidenceID *uint
|
||||||
|
DocumentType string
|
||||||
|
Category string
|
||||||
|
ContractorID *uint
|
||||||
|
IsActive *bool
|
||||||
|
ExpiringSoon *int
|
||||||
|
Tags string
|
||||||
|
Search string
|
||||||
|
}
|
||||||
|
|
||||||
// DocumentRepository handles database operations for documents
|
// DocumentRepository handles database operations for documents
|
||||||
type DocumentRepository struct {
|
type DocumentRepository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
@@ -55,6 +67,45 @@ func (r *DocumentRepository) FindByUser(residenceIDs []uint) ([]models.Document,
|
|||||||
return documents, err
|
return documents, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindByUserFiltered finds documents accessible to a user with optional filters.
|
||||||
|
func (r *DocumentRepository) FindByUserFiltered(residenceIDs []uint, filter *DocumentFilter) ([]models.Document, error) {
|
||||||
|
query := r.db.Preload("CreatedBy").
|
||||||
|
Preload("Residence").
|
||||||
|
Preload("Images").
|
||||||
|
Where("residence_id IN ? AND is_active = ?", residenceIDs, true)
|
||||||
|
|
||||||
|
if filter != nil {
|
||||||
|
if filter.DocumentType != "" {
|
||||||
|
query = query.Where("document_type = ?", filter.DocumentType)
|
||||||
|
}
|
||||||
|
if filter.Category != "" {
|
||||||
|
query = query.Where("category = ?", filter.Category)
|
||||||
|
}
|
||||||
|
if filter.ContractorID != nil {
|
||||||
|
query = query.Where("contractor_id = ?", *filter.ContractorID)
|
||||||
|
}
|
||||||
|
if filter.IsActive != nil {
|
||||||
|
query = query.Where("is_active = ?", *filter.IsActive)
|
||||||
|
}
|
||||||
|
if filter.ExpiringSoon != nil {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
threshold := now.AddDate(0, 0, *filter.ExpiringSoon)
|
||||||
|
query = query.Where("expiry_date IS NOT NULL AND expiry_date > ? AND expiry_date <= ?", now, threshold)
|
||||||
|
}
|
||||||
|
if filter.Tags != "" {
|
||||||
|
query = query.Where("tags LIKE ?", "%"+filter.Tags+"%")
|
||||||
|
}
|
||||||
|
if filter.Search != "" {
|
||||||
|
searchPattern := "%" + filter.Search + "%"
|
||||||
|
query = query.Where("(title ILIKE ? OR description ILIKE ?)", searchPattern, searchPattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var documents []models.Document
|
||||||
|
err := query.Order("created_at DESC").Find(&documents).Error
|
||||||
|
return documents, err
|
||||||
|
}
|
||||||
|
|
||||||
// FindWarranties finds all warranty documents
|
// FindWarranties finds all warranty documents
|
||||||
func (r *DocumentRepository) FindWarranties(residenceIDs []uint) ([]models.Document, error) {
|
func (r *DocumentRepository) FindWarranties(residenceIDs []uint) ([]models.Document, error) {
|
||||||
var documents []models.Document
|
var documents []models.Document
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ func (s *DocumentService) GetDocument(documentID, userID uint) (*responses.Docum
|
|||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListDocuments lists all documents accessible to a user
|
// ListDocuments lists all documents accessible to a user, with optional filters.
|
||||||
func (s *DocumentService) ListDocuments(userID uint) ([]responses.DocumentResponse, error) {
|
func (s *DocumentService) ListDocuments(userID uint, filter *repositories.DocumentFilter) ([]responses.DocumentResponse, error) {
|
||||||
// Get residence IDs (lightweight - no preloads)
|
// Get residence IDs (lightweight - no preloads)
|
||||||
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -68,7 +68,22 @@ func (s *DocumentService) ListDocuments(userID uint) ([]responses.DocumentRespon
|
|||||||
return []responses.DocumentResponse{}, nil
|
return []responses.DocumentResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
documents, err := s.documentRepo.FindByUser(residenceIDs)
|
// If a specific residence filter is set, narrow to that single residence (if user has access)
|
||||||
|
if filter != nil && filter.ResidenceID != nil {
|
||||||
|
found := false
|
||||||
|
for _, rid := range residenceIDs {
|
||||||
|
if rid == *filter.ResidenceID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, apperrors.Forbidden("error.residence_access_denied")
|
||||||
|
}
|
||||||
|
residenceIDs = []uint{*filter.ResidenceID}
|
||||||
|
}
|
||||||
|
|
||||||
|
documents, err := s.documentRepo.FindByUserFiltered(residenceIDs, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, apperrors.Internal(err)
|
return nil, apperrors.Internal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,11 @@ func (s *TaskService) GetTask(taskID, userID uint) (*responses.TaskResponse, err
|
|||||||
|
|
||||||
// ListTasks lists all tasks accessible to a user as a kanban board.
|
// ListTasks lists all tasks accessible to a user as a kanban board.
|
||||||
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
||||||
func (s *TaskService) ListTasks(userID uint, now time.Time) (*responses.KanbanBoardResponse, error) {
|
func (s *TaskService) ListTasks(userID uint, daysThreshold int, now time.Time) (*responses.KanbanBoardResponse, error) {
|
||||||
|
if daysThreshold <= 0 {
|
||||||
|
daysThreshold = 30 // Default
|
||||||
|
}
|
||||||
|
|
||||||
// Get all residence IDs accessible to user (lightweight - no preloads)
|
// Get all residence IDs accessible to user (lightweight - no preloads)
|
||||||
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -114,13 +118,13 @@ func (s *TaskService) ListTasks(userID uint, now time.Time) (*responses.KanbanBo
|
|||||||
// Return empty kanban board
|
// Return empty kanban board
|
||||||
return &responses.KanbanBoardResponse{
|
return &responses.KanbanBoardResponse{
|
||||||
Columns: []responses.KanbanColumnResponse{},
|
Columns: []responses.KanbanColumnResponse{},
|
||||||
DaysThreshold: 30,
|
DaysThreshold: daysThreshold,
|
||||||
ResidenceID: "all",
|
ResidenceID: "all",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get kanban data aggregated across all residences using user's timezone-aware time
|
// Get kanban data aggregated across all residences using user's timezone-aware time
|
||||||
board, err := s.taskRepo.GetKanbanDataForMultipleResidences(residenceIDs, 30, now)
|
board, err := s.taskRepo.GetKanbanDataForMultipleResidences(residenceIDs, daysThreshold, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, apperrors.Internal(err)
|
return nil, apperrors.Internal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ func TestTaskService_ListTasks(t *testing.T) {
|
|||||||
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 2")
|
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 2")
|
||||||
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 3")
|
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 3")
|
||||||
|
|
||||||
resp, err := service.ListTasks(user.ID, time.Now().UTC())
|
resp, err := service.ListTasks(user.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
// ListTasks returns a KanbanBoardResponse with columns
|
// ListTasks returns a KanbanBoardResponse with columns
|
||||||
// Count total tasks across all columns
|
// Count total tasks across all columns
|
||||||
|
|||||||
Reference in New Issue
Block a user