Fix task listing and filters, add project colors/icons

- Remove user membership filter from task query — show all org tasks, not just user's accessible ones
- Add color and icon fields to projects with DB migration and entity updates
- Fetch org members and projects upfront for filters, not just from task results
- Convert project and status filters to chips like categories, with color/icon display
- Add icon and color picker to project creation form

Backend changes:
- TaskRepositoryImpl: remove (pu.email = ?2 or tu.email = ?2) from WHERE clause
- TaskRepository interface: remove email param from findAccessibleTasksFiltered
- ListAllTasksUseCase: adjust call without email
- CreateProjectUseCase: accept color and icon params
- ProjectController: update request/response records, map new fields
- ProjectEntityMapper: map color/icon in both directions
- Add V3 migration for color/icon columns

Frontend changes:
- TaskListPage: fetch org members and projects on org change
- TaskFilterBar: replace dropdowns with chips for projects and status
- ProjectsPage: add color picker and icon picker to form
- Project type: add optional color/icon fields
- TaskAssignee: allow photo to be nullable

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Gabriel Santana de Menezes 2026-05-18 10:19:12 -04:00
parent 814bc51b86
commit 3f67d34306
14 changed files with 150 additions and 42 deletions

View file

@ -8,6 +8,8 @@ import net.beetechgroup.beetask.entities.organization.Organization;
public class Project {
private Long id;
private String name;
private String color;
private String icon;
private Organization organization;
private List<User> users;
@ -27,6 +29,22 @@ public class Project {
this.name = name;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public Organization getOrganization() {
return organization;
}

View file

@ -19,6 +19,8 @@ public class ProjectEntity {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String color;
private String icon;
@ManyToOne
@JoinColumn(name = "organization_id")
private OrganizationEntity organization;
@ -43,6 +45,21 @@ public class ProjectEntity {
this.name = name;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public OrganizationEntity getOrganization() {
return organization;

View file

@ -11,6 +11,8 @@ public class ProjectEntityMapper {
ProjectEntity entity = new ProjectEntity();
entity.setId(project.getId());
entity.setName(project.getName());
entity.setColor(project.getColor());
entity.setIcon(project.getIcon());
entity.setOrganization(OrganizationEntityMapper.toEntity(project.getOrganization()));
return entity;
}
@ -21,6 +23,8 @@ public class ProjectEntityMapper {
Project project = new Project();
project.setId(entity.getId());
project.setName(entity.getName());
project.setColor(entity.getColor());
project.setIcon(entity.getIcon());
project.setOrganization(OrganizationEntityMapper.toDomain(entity.getOrganization()));
return project;
}

View file

@ -53,17 +53,16 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
}
@Override
public List<Task> findAccessibleTasksFiltered(String email, Long organizationId, String text, List<Long> projectIds,
public List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds) {
StringBuilder query = new StringBuilder(
"select distinct t from TaskEntity t join t.project p left join p.users pu left join t.user tu " +
"where p.organization.id = ?1 and (pu.email = ?2 or tu.email = ?2)"
"select distinct t from TaskEntity t join t.project p " +
"where p.organization.id = ?1"
);
List<Object> params = new ArrayList<>();
params.add(organizationId);
params.add(email);
int idx = 3;
int idx = 2;
if (Objects.nonNull(text) && !text.isBlank()) {
query.append(" and (lower(t.title) like ?").append(idx).append(" or lower(t.description) like ?").append(idx + 1).append(")");
String pattern = "%" + text.toLowerCase() + "%";
@ -92,8 +91,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
}
List<Task> tasks = find(query.toString(), params.toArray()).list().stream().map(TaskEntityMapper::toDomain).toList();
LOGGER.infof("Loaded %d accessible tasks for user %s in organization %d with filters (text=%s, projectIds=%s, statuses=%s, categoryIds=%s, userIds=%s)",
tasks.size(), email, organizationId, text, projectIds, statuses, categoryIds, userIds);
LOGGER.infof("Loaded %d accessible tasks in organization %d with filters (text=%s, projectIds=%s, statuses=%s, categoryIds=%s, userIds=%s)",
tasks.size(), organizationId, text, projectIds, statuses, categoryIds, userIds);
return tasks;
}

View file

@ -32,7 +32,7 @@ public class ProjectController {
@Authenticated
public List<ProjectResponse> listProjects(@PathParam("orgId") Long orgId) {
List<ProjectResponse> projects = listProjectsUseCase.execute(orgId).stream()
.map(p -> new ProjectResponse(p.getId(), p.getName()))
.map(p -> new ProjectResponse(p.getId(), p.getName(), p.getColor(), p.getIcon()))
.toList();
LOGGER.infof("Listed %d projects for organization %d", projects.size(), orgId);
return projects;
@ -42,14 +42,14 @@ public class ProjectController {
@Authenticated
public ProjectResponse createProject(@PathParam("orgId") Long orgId, CreateProjectRequest request) {
LOGGER.infof("Project creation requested for organization %d with name '%s'", orgId, request.name());
Project project = createProjectUseCase.execute(orgId, request.name());
Project project = createProjectUseCase.execute(orgId, request.name(), request.color(), request.icon());
LOGGER.infof("Project %d created successfully for organization %d", project.getId(), orgId);
return new ProjectResponse(project.getId(), project.getName());
return new ProjectResponse(project.getId(), project.getName(), project.getColor(), project.getIcon());
}
// TODO must be a independent file
public record CreateProjectRequest(String name) {}
public record CreateProjectRequest(String name, String color, String icon) {}
// TODO must be a independent file
public record ProjectResponse(Long id, String name) {}
public record ProjectResponse(Long id, String name, String color, String icon) {}
}

View file

@ -16,7 +16,7 @@ public class CreateProjectUseCase {
this.organizationRepository = organizationRepository;
}
public Project execute(Long organizationId, String projectName) {
public Project execute(Long organizationId, String projectName, String color, String icon) {
Organization organization = organizationRepository.findOrganizationById(organizationId)
.orElseThrow(() -> {
LOGGER.warnf("Project creation failed because organization %d was not found", organizationId);
@ -26,6 +26,8 @@ public class CreateProjectUseCase {
LOGGER.infof("Creating project '%s' in organization %d", projectName, organizationId);
Project project = new Project();
project.setName(projectName);
project.setColor(color);
project.setIcon(icon);
project.setOrganization(organization);
Project savedProject = projectRepository.saveProject(project);

View file

@ -10,7 +10,7 @@ public interface TaskRepository {
Task saveTask(Task task);
Task findTaskById(Long id);
List<Task> findAllTasks();
List<Task> findAccessibleTasksFiltered(String email, Long organizationId, String text, List<Long> projectIds,
List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds);
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);

View file

@ -35,7 +35,6 @@ public class ListAllTasksUseCase {
}
List<CreateTaskOutput> tasks = taskRepository.findAccessibleTasksFiltered(
input.userEmail(),
input.organizationId(),
input.text(),
input.projectIds(),

View file

@ -0,0 +1,2 @@
ALTER TABLE projects ADD COLUMN color VARCHAR(9);
ALTER TABLE projects ADD COLUMN icon VARCHAR(64);

View file

@ -1,5 +1,5 @@
import { Filter, RefreshCw, Search } from 'lucide-react'
import { TASK_STATUS_LABELS, TASK_STATUS_OPTIONS } from '../types/task'
import { TASK_STATUS_LABELS, TASK_STATUS_OPTIONS, type TaskStatus } from '../types/task'
import type { Project } from '../services/projectService'
import type { Category } from '../types/category'
import { MultiSelectChips } from './MultiSelectChips'
@ -9,6 +9,13 @@ import type { TaskFilters } from './taskFilters'
import type { TaskAssignee } from '../types/task'
import { UserAvatar } from './UserAvatar'
const STATUS_CHIP_COLORS: Record<TaskStatus, string> = {
NOT_STARTED: '#94a3b8',
IN_PROGRESS: '#f59e0b',
COMPLETED: '#10b981',
CANCELED: '#ef4444',
}
interface TaskFilterBarProps {
filters: TaskFilters
onFiltersChange: (filters: TaskFilters) => void
@ -59,26 +66,31 @@ export function TaskFilterBar({
)}
</div>
<div className="grid gap-3 md:grid-cols-2">
{projects.length > 0 && (
<MultiSelectDropdown
label="Projetos"
allLabel="Todos os projetos"
options={projects.map((project) => ({
value: project.id,
label: project.name,
<div className="flex items-center gap-2">
<Filter size={16} className="shrink-0 text-text-muted" />
<span className="text-[10px] font-black uppercase tracking-widest text-text-muted whitespace-nowrap">Projetos</span>
<MultiSelectChips
options={projects.map((p) => ({
value: p.id,
label: p.name,
color: p.color ?? undefined,
icon: p.icon ? <CategoryIcon iconName={p.icon} size={14} /> : undefined,
}))}
selected={filters.projectIds}
onChange={(projectIds) => update({ projectIds })}
/>
</div>
)}
<MultiSelectDropdown
label="Status"
allLabel="Todos os status"
options={TASK_STATUS_OPTIONS.map((status) => ({
value: status,
label: TASK_STATUS_LABELS[status],
<div className="flex items-center gap-2">
<Filter size={16} className="shrink-0 text-text-muted" />
<span className="text-[10px] font-black uppercase tracking-widest text-text-muted whitespace-nowrap">Status</span>
<MultiSelectChips
options={TASK_STATUS_OPTIONS.map((s) => ({
value: s,
label: TASK_STATUS_LABELS[s],
color: STATUS_CHIP_COLORS[s],
}))}
selected={filters.statuses}
onChange={(statuses) => update({ statuses })}

View file

@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { useAuth } from "../contexts/AuthContext";
import { projectService, type Project } from "../services/projectService";
import { IconPicker } from "../components/IconPicker";
export const ProjectsPage: React.FC = () => {
const { activeOrg } = useAuth();
@ -21,6 +22,8 @@ export const ProjectsPage: React.FC = () => {
const [isCreating, setIsCreating] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [newProjectName, setNewProjectName] = useState("");
const [newProjectColor, setNewProjectColor] = useState("#6366f1");
const [newProjectIcon, setNewProjectIcon] = useState("Folder");
const [searchQuery, setSearchQuery] = useState("");
const [error, setError] = useState<string | null>(null);
@ -49,12 +52,15 @@ export const ProjectsPage: React.FC = () => {
setIsCreating(true);
setError(null);
try {
const newProject = await projectService.create(
activeOrg.id,
newProjectName,
);
const newProject = await projectService.create(activeOrg.id, {
name: newProjectName,
color: newProjectColor,
icon: newProjectIcon,
});
setProjects((prev) => [...prev, newProject]);
setNewProjectName("");
setNewProjectColor("#6366f1");
setNewProjectIcon("Folder");
setShowCreateForm(false);
} catch (err) {
console.error("Failed to create project", err);
@ -311,6 +317,34 @@ export const ProjectsPage: React.FC = () => {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-text-main ml-1">
Cor
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={newProjectColor}
onChange={(e) => setNewProjectColor(e.target.value)}
className="h-14 w-14 cursor-pointer rounded-xl border border-border-soft"
/>
<input
type="text"
value={newProjectColor}
onChange={(e) => setNewProjectColor(e.target.value)}
className="flex-1 px-4 py-3 bg-surface-muted/50 border border-border-soft rounded-xl text-text-main font-mono text-sm focus:outline-none focus:ring-4 focus:ring-brand/10 focus:border-brand transition-all"
placeholder="#6366f1"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-text-main ml-1">
Ícone
</label>
<IconPicker value={newProjectIcon} onChange={setNewProjectIcon} />
</div>
{error && (
<div className="p-4 bg-rose-500/10 border border-rose-500/20 rounded-2xl text-rose-500 text-xs font-bold flex items-center gap-3">
<X size={16} />
@ -320,7 +354,7 @@ export const ProjectsPage: React.FC = () => {
<button
type="submit"
disabled={isCreating || !newProjectName.trim()}
disabled={isCreating || !newProjectName.trim() || !newProjectColor || !newProjectIcon}
className="w-full flex items-center justify-center gap-3 bg-brand hover:bg-brand-dark disabled:bg-brand/50 disabled:cursor-not-allowed text-white font-black py-4 px-6 rounded-2xl transition-all shadow-lg shadow-brand/20 active:scale-[0.98]"
>
{isCreating ? (

View file

@ -16,7 +16,9 @@ import { Link, useNavigate } from 'react-router-dom'
import { taskService } from '../services/taskService'
import type { Project } from '../services/projectService'
import { projectService } from '../services/projectService'
import { categoryService } from '../services/categoryService'
import { organizationService } from '../services/organizationService'
import type { Category } from '../types/category'
import { cn } from '../lib/utils'
import type { TaskAssignee, TaskResponse } from '../types/task'
@ -61,7 +63,12 @@ function mergeProjects(existing: Project[], tasks: TaskResponse[]) {
tasks.forEach((task) => {
if (task.project) {
byId.set(task.project.id, { id: task.project.id, name: task.project.name })
byId.set(task.project.id, {
id: task.project.id,
name: task.project.name,
color: task.project.color,
icon: task.project.icon,
})
}
})
@ -120,11 +127,21 @@ export function TaskListPage() {
useEffect(() => {
if (!activeOrg) return
setFilters(DEFAULT_TASK_FILTERS)
setProjects([])
setAssignees([])
setCategories([])
void loadTasks(DEFAULT_TASK_FILTERS)
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {})
organizationService.listMembers(activeOrg.id).then((members) => {
setAssignees(members.map((m) => ({
id: m.userId,
name: m.userName,
email: m.userEmail,
photo: m.userPhoto ?? null,
})))
}).catch(() => {})
}, [activeOrg])
const handleFiltersChange = (newFilters: TaskFilters) => {

View file

@ -3,6 +3,8 @@ import { apiFetch } from '../lib/api'
export interface Project {
id: number
name: string
color?: string | null
icon?: string | null
}
export const projectService = {
@ -10,10 +12,10 @@ export const projectService = {
return apiFetch<Project[]>(`/organizations/${orgId}/projects`)
},
create: async (orgId: number, name: string): Promise<Project> => {
create: async (orgId: number, payload: { name: string; color: string; icon: string }): Promise<Project> => {
return apiFetch<Project>(`/organizations/${orgId}/projects`, {
method: 'POST',
body: { name },
body: payload,
})
},
}

View file

@ -33,6 +33,8 @@ export type TaskHistoryItem = {
export type TaskProject = {
id: number
name: string
color?: string | null
icon?: string | null
}
export type TaskCategory = {
@ -46,7 +48,7 @@ export type TaskAssignee = {
id: number
name: string
email: string
photo: string
photo: string | null
}
export type TaskResponse = {