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:
parent
814bc51b86
commit
3f67d34306
14 changed files with 150 additions and 42 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ public class ListAllTasksUseCase {
|
|||
}
|
||||
|
||||
List<CreateTaskOutput> tasks = taskRepository.findAccessibleTasksFiltered(
|
||||
input.userEmail(),
|
||||
input.organizationId(),
|
||||
input.text(),
|
||||
input.projectIds(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE projects ADD COLUMN color VARCHAR(9);
|
||||
ALTER TABLE projects ADD COLUMN icon VARCHAR(64);
|
||||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue