Changeset 545738f


Ignore:
Timestamp:
06/22/26 19:00:39 (2 weeks ago)
Author:
Andrej <asumanovski@…>
Branches:
master
Children:
c0086c0
Parents:
194c016
Message:

Switched to dockerized db and improved Task model and Task Ui

Files:
6 added
16 edited

Legend:

Unmodified
Added
Removed
  • backend/src/main/java/com/trekr/backend/config/SecurityConfig.java

    r194c016 r545738f  
    4949        config.setAllowedOrigins(List.of(
    5050                "http://localhost:5173",
    51                 "http://127.0.0.1:5173"));
     51                "http://127.0.0.1:5173",
     52                "http://localhost:3000",
     53                "http://127.0.0.1:3000"));
    5254        config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
    5355        config.setAllowedHeaders(List.of("Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"));
  • backend/src/main/java/com/trekr/backend/controller/CustomTrackingController.java

    r194c016 r545738f  
    99import com.trekr.backend.dto.discipline.UpdateTaskFinishedRequest;
    1010import com.trekr.backend.dto.discipline.UpdateTaskRequest;
     11import com.trekr.backend.dto.discipline.UpdateTaskStatusRequest;
    1112import com.trekr.backend.security.UserPrincipal;
    1213import com.trekr.backend.service.CustomTrackingService;
     
    7980    }
    8081
     82    @PatchMapping("/{customTrackingId}/tasks/{taskId}/status")
     83    public TaskDto updateTaskStatus(
     84            @PathVariable Long customTrackingId,
     85            @PathVariable Long taskId,
     86            @RequestBody UpdateTaskStatusRequest request,
     87            Authentication authentication) {
     88        UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
     89        return customTrackingService.updateCustomCategoryTaskStatus(principal.getUserId(), customTrackingId, taskId, request);
     90    }
     91
    8192    @DeleteMapping("/{customTrackingId}/tasks/{taskId}")
    8293    @ResponseStatus(HttpStatus.NO_CONTENT)
  • backend/src/main/java/com/trekr/backend/dto/discipline/CreateTaskRequest.java

    r194c016 r545738f  
    22
    33import jakarta.validation.constraints.NotBlank;
     4
     5import java.time.LocalDate;
    46
    57public class CreateTaskRequest {
     
    810    private String name;
    911
     12    private String description;
     13    private LocalDate dueDate;
     14    private String priority;
     15
    1016    public CreateTaskRequest() {
    1117    }
    1218
    13     public String getName() {
    14         return name;
    15     }
     19    public String getName() { return name; }
     20    public void setName(String name) { this.name = name; }
    1621
    17     public void setName(String name) {
    18         this.name = name;
    19     }
     22    public String getDescription() { return description; }
     23    public void setDescription(String description) { this.description = description; }
     24
     25    public LocalDate getDueDate() { return dueDate; }
     26    public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
     27
     28    public String getPriority() { return priority; }
     29    public void setPriority(String priority) { this.priority = priority; }
    2030}
    21 
  • backend/src/main/java/com/trekr/backend/dto/discipline/TaskDto.java

    r194c016 r545738f  
    11package com.trekr.backend.dto.discipline;
    22
    3 public record TaskDto(Long taskId, String name, boolean isFinished) {
     3import com.trekr.backend.entity.discipline.TaskStatus;
     4
     5import java.time.LocalDate;
     6
     7public record TaskDto(
     8        Long taskId,
     9        String name,
     10        boolean isFinished,
     11        TaskStatus status,
     12        String description,
     13        LocalDate dueDate,
     14        String priority
     15) {
    416}
    5 
  • backend/src/main/java/com/trekr/backend/dto/discipline/UpdateTaskRequest.java

    r194c016 r545738f  
    11package com.trekr.backend.dto.discipline;
    22
     3import com.trekr.backend.entity.discipline.TaskStatus;
    34import jakarta.validation.constraints.NotBlank;
     5
     6import java.time.LocalDate;
    47
    58public class UpdateTaskRequest {
     
    811    private String name;
    912
     13    private String description;
     14    private LocalDate dueDate;
     15    private String priority;
     16    private TaskStatus status;
     17
    1018    public UpdateTaskRequest() {
    1119    }
    1220
    13     public String getName() {
    14         return name;
    15     }
     21    public String getName() { return name; }
     22    public void setName(String name) { this.name = name; }
    1623
    17     public void setName(String name) {
    18         this.name = name;
    19     }
     24    public String getDescription() { return description; }
     25    public void setDescription(String description) { this.description = description; }
     26
     27    public LocalDate getDueDate() { return dueDate; }
     28    public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
     29
     30    public String getPriority() { return priority; }
     31    public void setPriority(String priority) { this.priority = priority; }
     32
     33    public TaskStatus getStatus() { return status; }
     34    public void setStatus(TaskStatus status) { this.status = status; }
    2035}
    21 
  • backend/src/main/java/com/trekr/backend/entity/discipline/Task.java

    r194c016 r545738f  
    33import jakarta.persistence.Column;
    44import jakarta.persistence.Entity;
     5import jakarta.persistence.EnumType;
     6import jakarta.persistence.Enumerated;
    57import jakarta.persistence.FetchType;
    68import jakarta.persistence.GeneratedValue;
     
    1012import jakarta.persistence.ManyToOne;
    1113import jakarta.persistence.Table;
     14
     15import java.time.LocalDate;
    1216
    1317@Entity
     
    2327    private String name;
    2428
    25     @Column(name = "is_finished", nullable = false)
    26     private boolean finished;
     29    @Enumerated(EnumType.STRING)
     30    @Column(name = "status", nullable = false)
     31    private TaskStatus status = TaskStatus.NOT_STARTED;
     32
     33    @Column(name = "description")
     34    private String description;
     35
     36    @Column(name = "due_date")
     37    private LocalDate dueDate;
     38
     39    @Column(name = "priority")
     40    private String priority;
    2741
    2842    @ManyToOne(fetch = FetchType.LAZY)
     
    3751    }
    3852
    39     public Long getTaskId() {
    40         return taskId;
     53    public Long getTaskId() { return taskId; }
     54    public void setTaskId(Long taskId) { this.taskId = taskId; }
     55
     56    public String getName() { return name; }
     57    public void setName(String name) { this.name = name; }
     58
     59    public TaskStatus getStatus() { return status; }
     60    public void setStatus(TaskStatus status) { this.status = status; }
     61
     62    public boolean isFinished() { return TaskStatus.FINISHED == status; }
     63    public void setFinished(boolean finished) {
     64        this.status = finished ? TaskStatus.FINISHED : TaskStatus.NOT_STARTED;
    4165    }
    4266
    43     public void setTaskId(Long taskId) {
    44         this.taskId = taskId;
    45     }
     67    public String getDescription() { return description; }
     68    public void setDescription(String description) { this.description = description; }
    4669
    47     public String getName() {
    48         return name;
    49     }
     70    public LocalDate getDueDate() { return dueDate; }
     71    public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
    5072
    51     public void setName(String name) {
    52         this.name = name;
    53     }
     73    public String getPriority() { return priority; }
     74    public void setPriority(String priority) { this.priority = priority; }
    5475
    55     public boolean isFinished() {
    56         return finished;
    57     }
     76    public DisciplineUser getDisciplineUser() { return disciplineUser; }
     77    public void setDisciplineUser(DisciplineUser disciplineUser) { this.disciplineUser = disciplineUser; }
    5878
    59     public void setFinished(boolean finished) {
    60         this.finished = finished;
    61     }
    62 
    63     public DisciplineUser getDisciplineUser() {
    64         return disciplineUser;
    65     }
    66 
    67     public void setDisciplineUser(DisciplineUser disciplineUser) {
    68         this.disciplineUser = disciplineUser;
    69     }
    70 
    71     public CustomTrackingCategory getCustomTrackingCategory() {
    72         return customTrackingCategory;
    73     }
    74 
     79    public CustomTrackingCategory getCustomTrackingCategory() { return customTrackingCategory; }
    7580    public void setCustomTrackingCategory(CustomTrackingCategory customTrackingCategory) {
    7681        this.customTrackingCategory = customTrackingCategory;
  • backend/src/main/java/com/trekr/backend/repository/TaskRepository.java

    r194c016 r545738f  
    22
    33import com.trekr.backend.entity.discipline.Task;
     4import com.trekr.backend.entity.discipline.TaskStatus;
    45import org.springframework.data.domain.Page;
    56import org.springframework.data.domain.Pageable;
     
    1920    long countByDisciplineUser_UserId(Long userId);
    2021
    21     long countByDisciplineUser_UserIdAndFinishedTrue(Long userId);
     22    long countByDisciplineUser_UserIdAndStatus(Long userId, TaskStatus status);
    2223
    23     List<Task> findByDisciplineUser_UserIdAndFinishedTrue(Long userId);
     24    List<Task> findByDisciplineUser_UserIdAndStatus(Long userId, TaskStatus status);
    2425
    2526    List<Task> findByCustomTrackingCategory_CustomTrackingIdAndCustomTrackingCategory_User_UserIdOrderByTaskIdDesc(
     
    3031
    3132    @Modifying
    32     @Query("update Task t set t.finished = false where t.disciplineUser.userId = :userId")
    33     int resetFinishedForUser(@Param("userId") Long userId);
     33    @Query("update Task t set t.status = :status where t.disciplineUser.userId = :userId")
     34    int resetStatusForUser(@Param("userId") Long userId, @Param("status") TaskStatus status);
    3435}
    35 
  • backend/src/main/java/com/trekr/backend/service/CustomTrackingService.java

    r194c016 r545738f  
    88import com.trekr.backend.dto.discipline.TasksResponse;
    99import com.trekr.backend.dto.discipline.UpdateTaskFinishedRequest;
     10import com.trekr.backend.dto.discipline.UpdateTaskStatusRequest;
     11import com.trekr.backend.entity.discipline.TaskStatus;
    1012import com.trekr.backend.dto.discipline.UpdateTaskRequest;
    1113import com.trekr.backend.entity.User;
     
    8183                        customTrackingId, userId)
    8284                .stream()
    83                 .map(t -> new TaskDto(t.getTaskId(), t.getName(), t.isFinished()))
     85                .map(t -> new TaskDto(t.getTaskId(), t.getName(), t.isFinished(), t.getStatus(), t.getDescription(), t.getDueDate(), t.getPriority()))
    8486                .toList();
    8587
     
    101103        Task task = new Task();
    102104        task.setName(name.trim());
    103         task.setFinished(false);
     105        task.setStatus(TaskStatus.NOT_STARTED);
     106        task.setDescription(request.getDescription());
     107        task.setDueDate(request.getDueDate());
     108        task.setPriority(request.getPriority());
    104109        task.setDisciplineUser(null);
    105110        task.setCustomTrackingCategory(category);
    106111
    107112        Task saved = taskRepository.save(task);
    108         return new TaskDto(saved.getTaskId(), saved.getName(), saved.isFinished());
     113        return new TaskDto(saved.getTaskId(), saved.getName(), saved.isFinished(), saved.getStatus(), saved.getDescription(), saved.getDueDate(), saved.getPriority());
    109114    }
    110115
     
    127132
    128133        task.setName(name.trim());
    129         Task saved = taskRepository.save(task);
    130         return new TaskDto(saved.getTaskId(), saved.getName(), saved.isFinished());
     134        task.setDescription(request.getDescription());
     135        task.setDueDate(request.getDueDate());
     136        task.setPriority(request.getPriority());
     137        if (request.getStatus() != null) {
     138            task.setStatus(request.getStatus());
     139        }
     140        Task saved = taskRepository.save(task);
     141        return new TaskDto(saved.getTaskId(), saved.getName(), saved.isFinished(), saved.getStatus(), saved.getDescription(), saved.getDueDate(), saved.getPriority());
    131142    }
    132143
     
    151162        task.setFinished(finished);
    152163        Task saved = taskRepository.save(task);
    153         return new TaskDto(saved.getTaskId(), saved.getName(), saved.isFinished());
     164        return new TaskDto(saved.getTaskId(), saved.getName(), saved.isFinished(), saved.getStatus(), saved.getDescription(), saved.getDueDate(), saved.getPriority());
     165    }
     166
     167    @Transactional
     168    public TaskDto updateCustomCategoryTaskStatus(Long userId, Long customTrackingId, Long taskId,
     169                                                  UpdateTaskStatusRequest request) {
     170        Task task = taskRepository.findById(taskId)
     171                .orElseThrow(() -> new RuntimeException("Task not found"));
     172
     173        if (task.getCustomTrackingCategory() == null
     174                || !customTrackingId.equals(task.getCustomTrackingCategory().getCustomTrackingId())
     175                || task.getCustomTrackingCategory().getUser() == null
     176                || !userId.equals(task.getCustomTrackingCategory().getUser().getUserId())) {
     177            throw new RuntimeException("Task not found");
     178        }
     179
     180        if (request.getStatus() == null) {
     181            throw new RuntimeException("status is required");
     182        }
     183
     184        task.setStatus(request.getStatus());
     185        Task saved = taskRepository.save(task);
     186        return new TaskDto(saved.getTaskId(), saved.getName(), saved.isFinished(), saved.getStatus(), saved.getDescription(), saved.getDueDate(), saved.getPriority());
    154187    }
    155188
  • backend/src/main/java/com/trekr/backend/service/DisciplineService.java

    r194c016 r545738f  
    1414import com.trekr.backend.entity.discipline.Task;
    1515import com.trekr.backend.entity.discipline.TaskDailyCompletion;
     16import com.trekr.backend.entity.discipline.TaskStatus;
    1617import com.trekr.backend.entity.discipline.TaskDailyCompletionId;
    1718import com.trekr.backend.repository.DailyCompletionRepository;
     
    8182
    8283        List<TaskDto> tasks = result.getContent().stream()
    83                 .map(t -> new TaskDto(t.getTaskId(), t.getName(), t.isFinished()))
     84                .map(t -> new TaskDto(t.getTaskId(), t.getName(), t.isFinished(), t.getStatus(), t.getDescription(), t.getDueDate(), t.getPriority()))
    8485                .toList();
    8586
     
    107108
    108109        Task saved = taskRepository.save(task);
    109         return new TaskDto(saved.getTaskId(), saved.getName(), saved.isFinished());
     110        return new TaskDto(saved.getTaskId(), saved.getName(), saved.isFinished(), saved.getStatus(), saved.getDescription(), saved.getDueDate(), saved.getPriority());
    110111    }
    111112
     
    126127        task.setName(name.trim());
    127128        Task saved = taskRepository.save(task);
    128         return new TaskDto(saved.getTaskId(), saved.getName(), saved.isFinished());
     129        return new TaskDto(saved.getTaskId(), saved.getName(), saved.isFinished(), saved.getStatus(), saved.getDescription(), saved.getDueDate(), saved.getPriority());
    129130    }
    130131
     
    145146        task.setFinished(finished);
    146147        Task saved = taskRepository.save(task);
    147         return new TaskDto(saved.getTaskId(), saved.getName(), saved.isFinished());
     148        return new TaskDto(saved.getTaskId(), saved.getName(), saved.isFinished(), saved.getStatus(), saved.getDescription(), saved.getDueDate(), saved.getPriority());
    148149    }
    149150
     
    187188
    188189        long total = taskRepository.countByDisciplineUser_UserId(userId);
    189         long finished = taskRepository.countByDisciplineUser_UserIdAndFinishedTrue(userId);
     190        long finished = taskRepository.countByDisciplineUser_UserIdAndStatus(userId, TaskStatus.FINISHED);
    190191
    191192        BigDecimal percent;
     
    210211        // (not strictly required for now, but matches schema and enables future analytics)
    211212        if (finished > 0) {
    212             List<TaskDailyCompletion> links = taskRepository.findByDisciplineUser_UserIdAndFinishedTrue(userId)
     213            List<TaskDailyCompletion> links = taskRepository.findByDisciplineUser_UserIdAndStatus(userId, TaskStatus.FINISHED)
    213214                    .stream()
    214215                    .map(t -> {
     
    224225
    225226        // Reset all tasks for next day
    226         taskRepository.resetFinishedForUser(userId);
     227        taskRepository.resetStatusForUser(userId, TaskStatus.NOT_STARTED);
    227228
    228229        DailyCompletionDto dto = new DailyCompletionDto(savedCompletion.getDailyCompletionId(),
  • db-scripts/ddl.sql

    r194c016 r545738f  
    9696    task_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    9797    name TEXT NOT NULL,
    98     is_finished BOOLEAN NOT NULL DEFAULT FALSE,
     98    status VARCHAR(20) NOT NULL DEFAULT 'NOT_STARTED',
     99    description TEXT,
     100    due_date DATE,
     101    priority VARCHAR(10),
    99102
    100103    discipline_user_id BIGINT
  • db-scripts/dml.sql

    r194c016 r545738f  
    262262);
    263263
    264 INSERT INTO TASKS (task_id, name, is_finished, discipline_user_id, custom_tracking_id) VALUES
    265 (1, 'Morning Run', FALSE, 1, NULL),
    266 (2, 'Read Book', FALSE, 1, NULL),
    267 (3, 'Meditation', FALSE, 2, NULL),
    268 (4, 'Code Review', FALSE, NULL, 1),
    269 (5, 'Push Updates', FALSE, NULL, 1),
    270 (6, 'Plan Day', FALSE, 3, NULL),
    271 (7, 'No Sugar', FALSE, 3, NULL),
    272 (8, 'Stretch', FALSE, 3, NULL),
    273 (9, 'Drink Water', FALSE, 4, NULL),
    274 (10, 'Walk 8k Steps', FALSE, 4, NULL),
    275 (11, 'Journal', FALSE, 5, NULL),
    276 (12, 'Gym', FALSE, 5, NULL),
    277 (13, 'Duolingo', FALSE, NULL, 2),
    278 (14, 'Watch Lesson', FALSE, NULL, 2),
    279 (15, 'Practice Problems', FALSE, NULL, 5),
    280 (16, 'Stretch 10m', FALSE, 7, NULL),
    281 (17, 'No Soda', FALSE, 7, NULL),
    282 (18, 'Walk 30m', FALSE, 8, NULL),
    283 (19, 'Sleep 7h', FALSE, 8, NULL),
    284 (20, 'Read 20p', FALSE, 9, NULL),
    285 (21, 'Meditate', FALSE, 9, NULL),
    286 (22, 'No Sugar', FALSE, 9, NULL),
    287 (23, 'Drink Water', FALSE, 10, NULL),
    288 (24, 'Morning Run', FALSE, 11, NULL),
    289 (25, 'Plan Day', FALSE, 11, NULL),
    290 (26, 'Study 1h', FALSE, 11, NULL),
    291 (27, 'Yoga', FALSE, 12, NULL),
    292 (28, 'Cook Healthy', FALSE, 12, NULL),
    293 (29, 'Gym', FALSE, 13, NULL),
    294 (30, 'Protein Target', FALSE, 13, NULL),
    295 (31, 'Journal', FALSE, 14, NULL),
    296 (32, 'Early Bed', FALSE, 14, NULL),
    297 (33, 'Duolingo', FALSE, 15, NULL),
    298 (34, 'No Alcohol', FALSE, 15, NULL),
    299 (35, 'Walk 8k Steps', FALSE, 15, NULL),
    300 (36, 'Attend Lecture', FALSE, NULL, 6),
    301 (37, 'Solve Tasks', FALSE, NULL, 6),
    302 (38, 'Review', FALSE, NULL, 6),
    303 (39, 'Steps', FALSE, NULL, 7),
    304 (40, 'Water', FALSE, NULL, 7),
    305 (41, 'Pitch', FALSE, NULL, 8),
    306 (42, 'Build', FALSE, NULL, 8),
    307 (43, 'Ship', FALSE, NULL, 8),
    308 (44, 'Meal Prep', FALSE, NULL, 9),
    309 (45, 'Groceries', FALSE, NULL, 9),
    310 (46, 'Read Fiction', FALSE, NULL, 10),
    311 (47, 'Read Nonfiction', FALSE, NULL, 10),
    312 (48, 'Standup', FALSE, NULL, 11),
    313 (49, 'Tickets', FALSE, NULL, 11),
    314 (50, 'PR Review', FALSE, NULL, 11),
    315 (51, 'Track Expenses', FALSE, NULL, 12),
    316 (52, 'Invest', FALSE, NULL, 12),
    317 (53, 'Duolingo Session', FALSE, NULL, 13),
    318 (54, 'Podcast', FALSE, NULL, 13),
    319 (55, 'Squat', FALSE, NULL, 14),
    320 (56, 'Bench', FALSE, NULL, 14),
    321 (57, 'Deadlift', FALSE, NULL, 14),
    322 (58, 'Practice', FALSE, NULL, 15),
    323 (59, 'Mock', FALSE, NULL, 15),
    324 (60, 'Review Exam', FALSE, NULL, 15);
     264INSERT INTO TASKS (task_id, name, status, discipline_user_id, custom_tracking_id) VALUES
     265(1, 'Morning Run', 'NOT_STARTED', 1, NULL),
     266(2, 'Read Book', 'NOT_STARTED', 1, NULL),
     267(3, 'Meditation', 'NOT_STARTED', 2, NULL),
     268(4, 'Code Review', 'NOT_STARTED', NULL, 1),
     269(5, 'Push Updates', 'NOT_STARTED', NULL, 1),
     270(6, 'Plan Day', 'NOT_STARTED', 3, NULL),
     271(7, 'No Sugar', 'NOT_STARTED', 3, NULL),
     272(8, 'Stretch', 'NOT_STARTED', 3, NULL),
     273(9, 'Drink Water', 'NOT_STARTED', 4, NULL),
     274(10, 'Walk 8k Steps', 'NOT_STARTED', 4, NULL),
     275(11, 'Journal', 'NOT_STARTED', 5, NULL),
     276(12, 'Gym', 'NOT_STARTED', 5, NULL),
     277(13, 'Duolingo', 'NOT_STARTED', NULL, 2),
     278(14, 'Watch Lesson', 'NOT_STARTED', NULL, 2),
     279(15, 'Practice Problems', 'NOT_STARTED', NULL, 5),
     280(16, 'Stretch 10m', 'NOT_STARTED', 7, NULL),
     281(17, 'No Soda', 'NOT_STARTED', 7, NULL),
     282(18, 'Walk 30m', 'NOT_STARTED', 8, NULL),
     283(19, 'Sleep 7h', 'NOT_STARTED', 8, NULL),
     284(20, 'Read 20p', 'NOT_STARTED', 9, NULL),
     285(21, 'Meditate', 'NOT_STARTED', 9, NULL),
     286(22, 'No Sugar', 'NOT_STARTED', 9, NULL),
     287(23, 'Drink Water', 'NOT_STARTED', 10, NULL),
     288(24, 'Morning Run', 'NOT_STARTED', 11, NULL),
     289(25, 'Plan Day', 'NOT_STARTED', 11, NULL),
     290(26, 'Study 1h', 'NOT_STARTED', 11, NULL),
     291(27, 'Yoga', 'NOT_STARTED', 12, NULL),
     292(28, 'Cook Healthy', 'NOT_STARTED', 12, NULL),
     293(29, 'Gym', 'NOT_STARTED', 13, NULL),
     294(30, 'Protein Target', 'NOT_STARTED', 13, NULL),
     295(31, 'Journal', 'NOT_STARTED', 14, NULL),
     296(32, 'Early Bed', 'NOT_STARTED', 14, NULL),
     297(33, 'Duolingo', 'NOT_STARTED', 15, NULL),
     298(34, 'No Alcohol', 'NOT_STARTED', 15, NULL),
     299(35, 'Walk 8k Steps', 'NOT_STARTED', 15, NULL),
     300(36, 'Attend Lecture', 'NOT_STARTED', NULL, 6),
     301(37, 'Solve Tasks', 'NOT_STARTED', NULL, 6),
     302(38, 'Review', 'NOT_STARTED', NULL, 6),
     303(39, 'Steps', 'NOT_STARTED', NULL, 7),
     304(40, 'Water', 'NOT_STARTED', NULL, 7),
     305(41, 'Pitch', 'NOT_STARTED', NULL, 8),
     306(42, 'Build', 'NOT_STARTED', NULL, 8),
     307(43, 'Ship', 'NOT_STARTED', NULL, 8),
     308(44, 'Meal Prep', 'NOT_STARTED', NULL, 9),
     309(45, 'Groceries', 'NOT_STARTED', NULL, 9),
     310(46, 'Read Fiction', 'NOT_STARTED', NULL, 10),
     311(47, 'Read Nonfiction', 'NOT_STARTED', NULL, 10),
     312(48, 'Standup', 'NOT_STARTED', NULL, 11),
     313(49, 'Tickets', 'NOT_STARTED', NULL, 11),
     314(50, 'PR Review', 'NOT_STARTED', NULL, 11),
     315(51, 'Track Expenses', 'NOT_STARTED', NULL, 12),
     316(52, 'Invest', 'NOT_STARTED', NULL, 12),
     317(53, 'Duolingo Session', 'NOT_STARTED', NULL, 13),
     318(54, 'Podcast', 'NOT_STARTED', NULL, 13),
     319(55, 'Squat', 'NOT_STARTED', NULL, 14),
     320(56, 'Bench', 'NOT_STARTED', NULL, 14),
     321(57, 'Deadlift', 'NOT_STARTED', NULL, 14),
     322(58, 'Practice', 'NOT_STARTED', NULL, 15),
     323(59, 'Mock', 'NOT_STARTED', NULL, 15),
     324(60, 'Review Exam', 'NOT_STARTED', NULL, 15);
    325325
    326326SELECT setval(
  • frontend/src/api/discipline.js

    r194c016 r545738f  
    8181}
    8282
     83export async function updateCustomCategoryTaskStatus(customTrackingId, taskId, status) {
     84  const res = await api.patch(
     85    `/discipline/custom-tracking-categories/${customTrackingId}/tasks/${taskId}/status`,
     86    { status }
     87  );
     88  return res?.data;
     89}
     90
    8391export async function deleteCustomCategoryTask(customTrackingId, taskId) {
    8492  return api.delete(
  • frontend/src/pages/Dashboard/pages/CustomCategory/CustomCategoryKanban.jsx

    r194c016 r545738f  
    88  getCustomTrackingCategories,
    99  updateCustomCategoryTask,
    10   updateCustomCategoryTaskFinished,
     10  updateCustomCategoryTaskStatus,
    1111} from "../../../../api/discipline.js";
    1212
     
    2323
    2424  const lanes = useMemo(() => {
    25     const notDone = (tasks ?? []).filter((t) => !t.isFinished);
    26     const done = (tasks ?? []).filter((t) => t.isFinished);
    27     return { notDone, done };
     25    const all = tasks ?? [];
     26    return {
     27      notStarted: all.filter((t) => t.status === "NOT_STARTED"),
     28      inProgress: all.filter((t) => t.status === "IN_PROGRESS"),
     29      finished: all.filter((t) => t.status === "FINISHED"),
     30    };
    2831  }, [tasks]);
    2932
     
    3639        getCustomCategoryTasks(customTrackingId),
    3740      ]);
    38 
    3941      const cats = catsRes?.items ?? [];
    4042      const found = cats.find((c) => Number(c.customTrackingId) === customTrackingId);
    4143      setCategoryName(found?.name ?? "Custom Category");
    42 
    4344      setTasks(tasksRes?.tasks ?? []);
    4445    } catch (e) {
     
    5960  }, [customTrackingId]);
    6061
    61   async function handleAddTask(name) {
    62     const created = await createCustomCategoryTask(customTrackingId, { name });
     62  async function handleAddTask(payload) {
     63    const created = await createCustomCategoryTask(customTrackingId, payload);
    6364    setTasks((prev) => [created, ...(prev ?? [])]);
    6465  }
    6566
    66   async function handleEditTask(taskId, name) {
    67     const updated = await updateCustomCategoryTask(customTrackingId, taskId, { name });
     67  async function handleEditTask(taskId, payload) {
     68    const updated = await updateCustomCategoryTask(customTrackingId, taskId, payload);
    6869    setTasks((prev) => (prev ?? []).map((t) => (t.taskId === taskId ? updated : t)));
    6970  }
     
    7475  }
    7576
    76   async function handleMoveTask(taskId, toFinished) {
    77     // optimistic update
     77  async function handleMoveTask(taskId, toStatus) {
    7878    setTasks((prev) =>
    79       (prev ?? []).map((t) =>
    80         t.taskId === taskId ? { ...t, isFinished: toFinished } : t
    81       )
     79      (prev ?? []).map((t) => (t.taskId === taskId ? { ...t, status: toStatus } : t))
    8280    );
    83 
    8481    try {
    85       const saved = await updateCustomCategoryTaskFinished(
    86         customTrackingId,
    87         taskId,
    88         toFinished
    89       );
     82      const saved = await updateCustomCategoryTaskStatus(customTrackingId, taskId, toStatus);
    9083      setTasks((prev) => (prev ?? []).map((t) => (t.taskId === taskId ? saved : t)));
    91     } catch (e) {
    92       // revert by refetching (simple + correct)
     84    } catch {
    9385      await refresh();
    94       throw e;
    9586    }
    9687  }
     
    10293          <h1 className="text-2xl font-bold">{categoryName}</h1>
    10394          <p className="text-sm opacity-80">
    104             Drag tickets between columns to mark them done / not done.
     95            Drag tickets between columns or use the edit form to update status.
    10596          </p>
    10697        </div>
     
    115106      <CustomCategoryKanbanBoard
    116107        loading={loading}
    117         notDone={lanes.notDone}
    118         done={lanes.done}
     108        notStarted={lanes.notStarted}
     109        inProgress={lanes.inProgress}
     110        finished={lanes.finished}
    119111        onAddTask={handleAddTask}
    120112        onEditTask={handleEditTask}
     
    125117  );
    126118}
    127 
    128 
  • frontend/src/pages/Dashboard/pages/CustomCategory/components/CustomCategoryKanbanBoard.jsx

    r194c016 r545738f  
    11import React, { useMemo, useState } from "react";
    2 
    3 import {
    4   DndContext,
    5   PointerSensor,
    6   closestCenter,
    7   useSensor,
    8   useSensors,
    9 } from "@dnd-kit/core";
    10 import {
    11   SortableContext,
    12   arrayMove,
    13   rectSortingStrategy,
    14 } from "@dnd-kit/sortable";
     2import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
     3import { SortableContext, rectSortingStrategy } from "@dnd-kit/sortable";
     4import { MdAdd, MdSort } from "react-icons/md";
    155
    166import KanbanColumn from "./KanbanColumn";
    177import TaskTicketCard from "./TaskTicketCard";
     8import TaskFormModal from "./TaskFormModal";
    189
    19 function laneIdForTask(task) {
    20   return task?.isFinished ? "done" : "notDone";
     10const LANES = [
     11  { id: "NOT_STARTED", droppableId: "lane:NOT_STARTED", title: "Not Started" },
     12  { id: "IN_PROGRESS", droppableId: "lane:IN_PROGRESS", title: "In Progress" },
     13  { id: "FINISHED",    droppableId: "lane:FINISHED",    title: "Finished" },
     14];
     15
     16const PRIORITY_ORDER = { HIGH: 0, MEDIUM: 1, LOW: 2, null: 3, undefined: 3 };
     17
     18function sortTasks(tasks, sortBy) {
     19  if (!tasks || tasks.length === 0) return tasks;
     20  const arr = [...tasks];
     21  if (sortBy === "dueDate") {
     22    arr.sort((a, b) => {
     23      if (!a.dueDate && !b.dueDate) return 0;
     24      if (!a.dueDate) return 1;
     25      if (!b.dueDate) return -1;
     26      return new Date(a.dueDate) - new Date(b.dueDate);
     27    });
     28  } else if (sortBy === "priority") {
     29    arr.sort((a, b) => (PRIORITY_ORDER[a.priority] ?? 3) - (PRIORITY_ORDER[b.priority] ?? 3));
     30  }
     31  return arr;
     32}
     33
     34function statusForDropId(dropId) {
     35  return dropId.replace("lane:", "");
    2136}
    2237
    2338export default function CustomCategoryKanbanBoard({
    24   loading,
    25   notDone,
    26   done,
    27   onAddTask,
    28   onEditTask,
    29   onDeleteTask,
    30   onMoveTask,
     39  loading, notStarted, inProgress, finished,
     40  onAddTask, onEditTask, onDeleteTask, onMoveTask,
    3141}) {
    32   const [newName, setNewName] = useState("");
    33   const [submitting, setSubmitting] = useState(false);
     42  const [modalOpen, setModalOpen] = useState(false);
     43  const [editingTask, setEditingTask] = useState(null);
     44  const [modalLoading, setModalLoading] = useState(false);
     45  const [sortBy, setSortBy] = useState("default");
    3446
    3547  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
    3648
    37   // Keep a local order per lane for nicer UX, but still driven by server state.
    38   const [notDoneOrder, setNotDoneOrder] = useState([]);
    39   const [doneOrder, setDoneOrder] = useState([]);
     49  const tasksByLane = useMemo(() => ({
     50    NOT_STARTED: sortTasks(notStarted ?? [], sortBy),
     51    IN_PROGRESS: sortTasks(inProgress ?? [], sortBy),
     52    FINISHED:    sortTasks(finished ?? [], sortBy),
     53  }), [notStarted, inProgress, finished, sortBy]);
    4054
    41   const orderedNotDone = useMemo(() => {
    42     const byId = new Map((notDone ?? []).map((t) => [String(t.taskId), t]));
    43     const ids = [
    44       ...notDoneOrder.filter((id) => byId.has(id)),
    45       ...Array.from(byId.keys()).filter((id) => !notDoneOrder.includes(id)),
    46     ];
    47     return ids.map((id) => byId.get(id));
    48   }, [notDone, notDoneOrder]);
     55  const allTasks = useMemo(() => [
     56    ...(notStarted ?? []),
     57    ...(inProgress ?? []),
     58    ...(finished ?? []),
     59  ], [notStarted, inProgress, finished]);
    4960
    50   const orderedDone = useMemo(() => {
    51     const byId = new Map((done ?? []).map((t) => [String(t.taskId), t]));
    52     const ids = [
    53       ...doneOrder.filter((id) => byId.has(id)),
    54       ...Array.from(byId.keys()).filter((id) => !doneOrder.includes(id)),
    55     ];
    56     return ids.map((id) => byId.get(id));
    57   }, [done, doneOrder]);
     61  function findTask(id) {
     62    return allTasks.find((t) => String(t.taskId) === String(id));
     63  }
    5864
    59   async function submitNewTask(e) {
    60     e.preventDefault();
    61     const name = newName.trim();
    62     if (!name || submitting) return;
    63     setSubmitting(true);
    64     try {
    65       await onAddTask(name);
    66       setNewName("");
    67     } finally {
    68       setSubmitting(false);
     65  async function handleDragEnd({ active, over }) {
     66    if (!over) return;
     67    const activeId = String(active.id);
     68    const overId = String(over.id);
     69    if (activeId === overId) return;
     70
     71    const task = findTask(activeId);
     72    if (!task) return;
     73
     74    if (overId.startsWith("lane:")) {
     75      const toStatus = statusForDropId(overId);
     76      if (task.status !== toStatus) await onMoveTask(task.taskId, toStatus);
     77      return;
     78    }
     79
     80    const overTask = findTask(overId);
     81    if (!overTask) return;
     82
     83    if (task.status !== overTask.status) {
     84      await onMoveTask(task.taskId, overTask.status);
    6985    }
    7086  }
    7187
    72   function findTaskById(id) {
    73     const all = [...(notDone ?? []), ...(done ?? [])];
    74     return all.find((t) => String(t.taskId) === String(id));
    75   }
    76 
    77   async function handleDragEnd(event) {
    78     const { active, over } = event;
    79     if (!over) return;
    80 
    81     const activeId = String(active.id);
    82     const overId = String(over.id);
    83 
    84     if (activeId === overId) return;
    85 
    86     const task = findTaskById(activeId);
    87     if (!task) return;
    88 
    89     // Moving over a column dropzone
    90     if (overId === "lane:notDone" || overId === "lane:done") {
    91       const toFinished = overId === "lane:done";
    92       if (Boolean(task.isFinished) === toFinished) return;
    93       await onMoveTask(task.taskId, toFinished);
    94       return;
    95     }
    96 
    97     // Sorting within a lane: keep it frontend-only.
    98     const overTask = findTaskById(overId);
    99     if (!overTask) return;
    100 
    101     const fromLane = laneIdForTask(task);
    102     const toLane = laneIdForTask(overTask);
    103 
    104     if (fromLane !== toLane) {
    105       // if dropped onto a card in the other lane => treat as lane move
    106       await onMoveTask(task.taskId, toLane === "done");
    107       return;
    108     }
    109 
    110     if (fromLane === "notDone") {
    111       const ids = orderedNotDone.map((t) => String(t.taskId));
    112       const oldIndex = ids.indexOf(activeId);
    113       const newIndex = ids.indexOf(overId);
    114       if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
    115         setNotDoneOrder(arrayMove(ids, oldIndex, newIndex));
     88  async function handleModalSubmit(payload) {
     89    setModalLoading(true);
     90    try {
     91      if (editingTask) {
     92        await onEditTask(editingTask.taskId, payload);
     93      } else {
     94        await onAddTask(payload);
    11695      }
    117     } else {
    118       const ids = orderedDone.map((t) => String(t.taskId));
    119       const oldIndex = ids.indexOf(activeId);
    120       const newIndex = ids.indexOf(overId);
    121       if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
    122         setDoneOrder(arrayMove(ids, oldIndex, newIndex));
    123       }
     96      setModalOpen(false);
     97      setEditingTask(null);
     98    } finally {
     99      setModalLoading(false);
    124100    }
    125101  }
    126102
     103  const total = allTasks.length;
     104
    127105  return (
    128     <div className="space-y-6">
    129       <div className="rounded-xl border border-white/10 bg-neutral-900/40 p-4">
    130         <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
    131           <div>
    132             <div className="text-sm font-semibold text-white/90">
    133               Tickets
    134               <span className="ml-2 text-xs font-normal text-white/60">
    135                 Drag cards left/right to change status.
    136               </span>
    137             </div>
    138             <div className="mt-1 text-xs text-white/55">
    139               Tip: grab the <span className="font-semibold text-white/80">drag</span> handle.
    140             </div>
     106    <div className="space-y-4">
     107      <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/10 bg-neutral-900/40 px-4 py-3">
     108        <div className="flex flex-wrap items-center gap-3 text-xs text-white/70">
     109          <span className="rounded-full bg-slate-500/20 px-2 py-1 text-slate-200">
     110            Not started: {(notStarted ?? []).length}
     111          </span>
     112          <span className="rounded-full bg-amber-500/15 px-2 py-1 text-amber-200">
     113            In progress: {(inProgress ?? []).length}
     114          </span>
     115          <span className="rounded-full bg-emerald-500/15 px-2 py-1 text-emerald-200">
     116            Finished: {(finished ?? []).length}
     117          </span>
     118          <span className="text-white/40">/ {total} total</span>
     119        </div>
     120
     121        <div className="flex items-center gap-2">
     122          <div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-black/30 px-2 py-1.5">
     123            <MdSort className="size-4 text-white/50" />
     124            <span className="text-xs text-white/50">Sort:</span>
     125            <select
     126              value={sortBy}
     127              onChange={(e) => setSortBy(e.target.value)}
     128              className="bg-transparent text-xs text-white/80 outline-none cursor-pointer"
     129            >
     130              <option value="default">Default</option>
     131              <option value="dueDate">Due date</option>
     132              <option value="priority">Priority</option>
     133            </select>
    141134          </div>
    142135
    143           <div className="flex items-center gap-3 text-xs text-white/70">
    144             <span className="rounded-full bg-sky-500/15 px-2 py-1 text-sky-200">
    145               Not done: {(notDone ?? []).length}
    146             </span>
    147             <span className="rounded-full bg-emerald-500/15 px-2 py-1 text-emerald-200">
    148               Done: {(done ?? []).length}
    149             </span>
    150           </div>
     136          <button
     137            onClick={() => { setEditingTask(null); setModalOpen(true); }}
     138            className="flex items-center gap-1.5 rounded-lg bg-green-400 px-3 py-1.5 text-sm font-medium text-black hover:bg-green-300"
     139          >
     140            <MdAdd className="size-4" />
     141            New ticket
     142          </button>
    151143        </div>
    152 
    153         <form onSubmit={submitNewTask} className="mt-4 flex flex-col gap-3 md:flex-row">
    154           <input
    155             value={newName}
    156             onChange={(e) => setNewName(e.target.value)}
    157             placeholder="Add a new ticket (e.g. Fix bug #123)"
    158             maxLength={200}
    159             className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-green-400"
    160           />
    161           <button
    162             className="rounded-lg bg-green-400 px-4 py-2 text-sm font-medium text-black disabled:opacity-60"
    163             type="submit"
    164             disabled={!newName.trim() || submitting}
    165           >
    166             {submitting ? "Adding..." : "Add"}
    167           </button>
    168         </form>
    169144      </div>
    170145
    171       <DndContext
    172         sensors={sensors}
    173         collisionDetection={closestCenter}
    174         onDragEnd={handleDragEnd}
    175       >
    176         <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
    177           <KanbanColumn
    178             title="Not done"
    179             droppableId="lane:notDone"
    180             count={orderedNotDone.length}
    181           >
    182             <SortableContext
    183               items={orderedNotDone.map((t) => String(t.taskId))}
    184               strategy={rectSortingStrategy}
    185             >
    186               <div className="space-y-3">
    187                 {loading ? (
    188                   <div className="text-sm opacity-70">Loading...</div>
    189                 ) : orderedNotDone.length === 0 ? (
    190                   <div className="text-sm opacity-70">No tickets yet.</div>
    191                 ) : (
    192                   orderedNotDone.map((t) => (
    193                     <TaskTicketCard
    194                       key={t.taskId}
    195                       task={t}
    196                       onEdit={(name) => onEditTask(t.taskId, name)}
    197                       onDelete={() => onDeleteTask(t.taskId)}
    198                     />
    199                   ))
    200                 )}
    201               </div>
    202             </SortableContext>
    203           </KanbanColumn>
    204 
    205           <KanbanColumn
    206             title="Done"
    207             droppableId="lane:done"
    208             count={orderedDone.length}
    209           >
    210             <SortableContext
    211               items={orderedDone.map((t) => String(t.taskId))}
    212               strategy={rectSortingStrategy}
    213             >
    214               <div className="space-y-3">
    215                 {loading ? (
    216                   <div className="text-sm opacity-70">Loading...</div>
    217                 ) : orderedDone.length === 0 ? (
    218                   <div className="text-sm opacity-70">Nothing done yet.</div>
    219                 ) : (
    220                   orderedDone.map((t) => (
    221                     <TaskTicketCard
    222                       key={t.taskId}
    223                       task={t}
    224                       onEdit={(name) => onEditTask(t.taskId, name)}
    225                       onDelete={() => onDeleteTask(t.taskId)}
    226                     />
    227                   ))
    228                 )}
    229               </div>
    230             </SortableContext>
    231           </KanbanColumn>
     146      <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
     147        <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
     148          {LANES.map((lane) => {
     149            const tasks = tasksByLane[lane.id] ?? [];
     150            return (
     151              <KanbanColumn
     152                key={lane.id}
     153                laneId={lane.id}
     154                title={lane.title}
     155                droppableId={lane.droppableId}
     156                count={tasks.length}
     157              >
     158                <SortableContext
     159                  items={tasks.map((t) => String(t.taskId))}
     160                  strategy={rectSortingStrategy}
     161                >
     162                  <div className="space-y-3">
     163                    {loading ? (
     164                      <div className="text-sm opacity-70">Loading...</div>
     165                    ) : tasks.length === 0 ? (
     166                      <div className="text-sm opacity-40">No tickets.</div>
     167                    ) : (
     168                      tasks.map((t) => (
     169                        <TaskTicketCard
     170                          key={t.taskId}
     171                          task={t}
     172                          onEdit={() => { setEditingTask(t); setModalOpen(true); }}
     173                          onDelete={() => onDeleteTask(t.taskId)}
     174                        />
     175                      ))
     176                    )}
     177                  </div>
     178                </SortableContext>
     179              </KanbanColumn>
     180            );
     181          })}
    232182        </div>
    233183      </DndContext>
     184
     185      <TaskFormModal
     186        isOpen={modalOpen}
     187        onClose={() => { setModalOpen(false); setEditingTask(null); }}
     188        onSubmit={handleModalSubmit}
     189        task={editingTask}
     190        isLoading={modalLoading}
     191      />
    234192    </div>
    235193  );
    236194}
    237 
    238 
  • frontend/src/pages/Dashboard/pages/CustomCategory/components/KanbanColumn.jsx

    r194c016 r545738f  
    11import React, { useMemo } from "react";
    22import { useDroppable } from "@dnd-kit/core";
    3 import { MdCheckCircle, MdRadioButtonUnchecked } from "react-icons/md";
     3import { MdCheckCircle, MdRadioButtonUnchecked, MdAutorenew } from "react-icons/md";
    44
    5 export default function KanbanColumn({ title, droppableId, count, children }) {
     5const VARIANTS = {
     6  NOT_STARTED: {
     7    Icon: MdRadioButtonUnchecked,
     8    header: "from-slate-500/20 via-slate-500/8 to-transparent",
     9    badge: "bg-slate-500/15 text-slate-200 border-slate-500/30",
     10    ring: "group-hover:ring-slate-500/25",
     11    drop: "border-slate-400/60 bg-slate-400/10",
     12    border: "border-slate-500/20",
     13  },
     14  IN_PROGRESS: {
     15    Icon: MdAutorenew,
     16    header: "from-amber-500/20 via-amber-500/8 to-transparent",
     17    badge: "bg-amber-500/15 text-amber-200 border-amber-500/30",
     18    ring: "group-hover:ring-amber-500/25",
     19    drop: "border-amber-400/60 bg-amber-400/10",
     20    border: "border-amber-500/20",
     21  },
     22  FINISHED: {
     23    Icon: MdCheckCircle,
     24    header: "from-emerald-500/25 via-emerald-500/10 to-transparent",
     25    badge: "bg-emerald-500/15 text-emerald-200 border-emerald-500/30",
     26    ring: "group-hover:ring-emerald-500/25",
     27    drop: "border-emerald-400/60 bg-emerald-400/10",
     28    border: "border-emerald-500/20",
     29  },
     30};
     31
     32export default function KanbanColumn({ laneId, title, droppableId, count, children }) {
    633  const { setNodeRef, isOver } = useDroppable({ id: droppableId });
    7 
    8   const variant = useMemo(() => {
    9     if (droppableId === "lane:done") {
    10       return {
    11         Icon: MdCheckCircle,
    12         header: "from-emerald-500/25 via-emerald-500/10 to-transparent",
    13         badge: "bg-emerald-500/15 text-emerald-200 border-emerald-500/30",
    14         ring: "group-hover:ring-emerald-500/25",
    15         drop: "border-emerald-400/60 bg-emerald-400/10",
    16         border: "border-emerald-500/20",
    17       };
    18     }
    19 
    20     return {
    21       Icon: MdRadioButtonUnchecked,
    22       header: "from-sky-500/25 via-sky-500/10 to-transparent",
    23       badge: "bg-sky-500/15 text-sky-200 border-sky-500/30",
    24       ring: "group-hover:ring-sky-500/25",
    25       drop: "border-sky-400/60 bg-sky-400/10",
    26       border: "border-sky-500/20",
    27     };
    28   }, [droppableId]);
     34  const v = VARIANTS[laneId] ?? VARIANTS.NOT_STARTED;
    2935
    3036  return (
     
    3238      className={
    3339        "group overflow-hidden rounded-xl border bg-neutral-900/40 shadow-sm ring-1 ring-white/5 transition " +
    34         variant.border +
    35         " " +
    36         variant.ring
     40        v.border + " " + v.ring
    3741      }
    3842    >
    39       <div className={"bg-gradient-to-b " + variant.header + " p-4"}>
     43      <div className={"bg-gradient-to-b " + v.header + " p-4"}>
    4044        <div className="flex items-center justify-between gap-3">
    4145          <div className="flex items-center gap-2">
    42             <variant.Icon className="size-5 text-white/80" />
    43             <h2 className="text-sm font-semibold tracking-wide text-white/90">
    44               {title}
    45             </h2>
     46            <v.Icon className="size-5 text-white/80" />
     47            <h2 className="text-sm font-semibold tracking-wide text-white/90">{title}</h2>
    4648          </div>
    47           <span
    48             className={
    49               "rounded-full border px-2 py-0.5 text-xs font-medium " +
    50               variant.badge
    51             }
    52           >
     49          <span className={"rounded-full border px-2 py-0.5 text-xs font-medium " + v.badge}>
    5350            {count}
    5451          </span>
     
    6057        className={
    6158          "min-h-[220px] p-4 transition-colors " +
    62           (isOver ? variant.drop : "border-t border-white/10")
     59          (isOver ? v.drop : "border-t border-white/10")
    6360        }
    6461      >
     
    6865  );
    6966}
    70 
  • frontend/src/pages/Dashboard/pages/CustomCategory/components/TaskTicketCard.jsx

    r194c016 r545738f  
    1 import React, { useMemo, useState } from "react";
     1import React, { useMemo } from "react";
    22import { useSortable } from "@dnd-kit/sortable";
    33import { CSS } from "@dnd-kit/utilities";
    4 import { MdDelete, MdDragIndicator, MdEdit, MdSave } from "react-icons/md";
     4import { MdDelete, MdDragIndicator, MdEdit, MdCalendarToday } from "react-icons/md";
     5
     6const PRIORITY_STYLES = {
     7  HIGH:   "bg-red-500/15 text-red-300 border-red-500/30",
     8  MEDIUM: "bg-amber-500/15 text-amber-300 border-amber-500/30",
     9  LOW:    "bg-sky-500/15 text-sky-300 border-sky-500/30",
     10};
     11
     12const STATUS_DOT = {
     13  NOT_STARTED: "bg-slate-400",
     14  IN_PROGRESS: "bg-amber-400",
     15  FINISHED:    "bg-emerald-400",
     16};
     17
     18function formatDate(dateStr) {
     19  if (!dateStr) return null;
     20  const d = new Date(dateStr);
     21  return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
     22}
     23
     24function isOverdue(dateStr) {
     25  if (!dateStr) return false;
     26  return new Date(dateStr) < new Date(new Date().toDateString());
     27}
    528
    629export default function TaskTicketCard({ task, onEdit, onDelete }) {
    730  const id = String(task.taskId);
    8   const {
    9     attributes,
    10     listeners,
    11     setNodeRef,
    12     transform,
     31  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
     32
     33  const style = useMemo(() => ({
     34    transform: CSS.Transform.toString(transform),
    1335    transition,
    14     isDragging,
    15   } = useSortable({ id });
     36  }), [transform, transition]);
    1637
    17   const style = useMemo(
    18     () => ({
    19       transform: CSS.Transform.toString(transform),
    20       transition,
    21     }),
    22     [transform, transition]
    23   );
    24 
    25   const [editing, setEditing] = useState(false);
    26   const [draft, setDraft] = useState(task.name ?? "");
    27 
    28   async function submitEdit(e) {
    29     e.preventDefault();
    30     const next = draft.trim();
    31     if (!next) return;
    32     await onEdit(next);
    33     setEditing(false);
    34   }
     38  const overdue = task.status !== "FINISHED" && isOverdue(task.dueDate);
    3539
    3640  return (
     
    4044      className={
    4145        "group rounded-lg border border-white/10 bg-black/30 p-3 shadow-sm transition hover:border-white/20 hover:bg-black/35 " +
    42         (task.isFinished ? "opacity-90" : "") +
    43         (isDragging ? " opacity-60" : "")
     46        (task.status === "FINISHED" ? "opacity-80" : "") +
     47        (isDragging ? " opacity-50 scale-95" : "")
    4448      }
    4549    >
    46       <div className="flex items-start justify-between gap-3">
     50      {/* Header row */}
     51      <div className="flex items-start justify-between gap-2">
    4752        <div className="min-w-0 flex-1">
    48           {editing ? (
    49             <form onSubmit={submitEdit} className="flex gap-2">
    50               <input
    51                 value={draft}
    52                 onChange={(e) => setDraft(e.target.value)}
    53                 maxLength={200}
    54                 className="w-full rounded-md border border-white/10 bg-black/40 px-2 py-1 text-sm outline-none focus:border-green-400"
    55                 autoFocus
    56               />
    57               <button
    58                 type="submit"
    59                 className="inline-flex items-center gap-1 rounded-md bg-green-400 px-2 py-1 text-sm font-medium text-black"
    60               >
    61                 <MdSave className="size-4" />
    62                 Save
    63               </button>
    64             </form>
    65           ) : (
    66             <div className="flex items-center gap-2">
    67               <span
    68                 className={
    69                   "h-2 w-2 shrink-0 rounded-full " +
    70                   (task.isFinished ? "bg-emerald-400" : "bg-sky-400")
    71                 }
    72               />
    73               <div className="min-w-0 text-sm font-medium text-white/90">
    74                 {task.name}
    75               </div>
    76             </div>
    77           )}
     53          <div className="flex items-center gap-2">
     54            <span className={"h-2 w-2 shrink-0 rounded-full " + (STATUS_DOT[task.status] ?? "bg-slate-400")} />
     55            <span className="text-sm font-medium text-white/90 leading-snug">{task.name}</span>
     56          </div>
    7857
    79           <div className="mt-1 text-xs text-white/50">Ticket #{task.taskId}</div>
     58          {task.description ? (
     59            <p className="mt-1.5 text-xs text-white/50 line-clamp-2">{task.description}</p>
     60          ) : null}
    8061        </div>
    8162
    82         <div className="flex shrink-0 items-center gap-2">
    83           {!editing ? (
    84             <button
    85               type="button"
    86               onClick={() => {
    87                 setDraft(task.name ?? "");
    88                 setEditing(true);
    89               }}
    90               className="rounded-md p-1 text-white/70 hover:bg-white/10 hover:text-white"
    91               title="Edit"
    92             >
    93               <MdEdit className="size-5" />
    94             </button>
    95           ) : null}
    96 
     63        <div className="flex shrink-0 items-center gap-1">
     64          <button
     65            type="button"
     66            onClick={onEdit}
     67            className="rounded-md p-1 text-white/50 hover:bg-white/10 hover:text-white"
     68            title="Edit"
     69          >
     70            <MdEdit className="size-4" />
     71          </button>
    9772          <button
    9873            type="button"
    9974            onClick={onDelete}
    100             className="rounded-md p-1 text-white/70 hover:bg-white/10 hover:text-white"
     75            className="rounded-md p-1 text-white/50 hover:bg-white/10 hover:text-white"
    10176            title="Delete"
    10277          >
    103             <MdDelete className="size-5" />
     78            <MdDelete className="size-4" />
    10479          </button>
    105 
    10680          <button
    10781            type="button"
    108             className="inline-flex cursor-grab items-center gap-1 rounded-md p-1 text-white/70 hover:bg-white/10 hover:text-white active:cursor-grabbing"
     82            className="cursor-grab rounded-md p-1 text-white/50 hover:bg-white/10 hover:text-white active:cursor-grabbing"
    10983            title="Drag"
    11084            {...attributes}
    11185            {...listeners}
    11286          >
    113             <MdDragIndicator className="size-5" />
     87            <MdDragIndicator className="size-4" />
    11488          </button>
    11589        </div>
     90      </div>
     91
     92      {/* Footer row: priority + due date */}
     93      <div className="mt-2 flex flex-wrap items-center gap-2">
     94        <span className="text-xs text-white/30">#{task.taskId}</span>
     95
     96        {task.priority ? (
     97          <span className={"rounded-full border px-2 py-0.5 text-xs font-medium " + (PRIORITY_STYLES[task.priority] ?? PRIORITY_STYLES.LOW)}>
     98            {task.priority.charAt(0) + task.priority.slice(1).toLowerCase()}
     99          </span>
     100        ) : null}
     101
     102        {task.dueDate ? (
     103          <span className={"flex items-center gap-1 text-xs " + (overdue ? "text-red-400" : "text-white/40")}>
     104            <MdCalendarToday className="size-3" />
     105            {overdue ? "Overdue · " : ""}{formatDate(task.dueDate)}
     106          </span>
     107        ) : null}
    116108      </div>
    117109    </div>
    118110  );
    119111}
    120 
Note: See TracChangeset for help on using the changeset viewer.