fix: include projectless tasks in org dashboard and member detail stats

Tasks with no associated project were being excluded from org dashboard
calculations because the query filtered by project.organization.id.
Now fetches tasks by active member emails, so all tasks are counted.
Tasks and history items with no project are grouped under "Geral" in
project-specific charts and calculations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gabriel Santana de Menezes 2026-05-09 20:52:54 -04:00
parent 824d33f283
commit 20c69bcb7e
7 changed files with 57 additions and 32 deletions

View file

@ -97,4 +97,24 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
userId, TaskStatus.COMPLETED, start, end)
.list().stream().map(TaskEntityMapper::toDomain).toList();
}
@Override
public List<Task> findTasksWorkedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end) {
if (emails.isEmpty()) return List.of();
return find("user.email in ?1", emails).list().stream()
.filter(entity -> entity.getHistory().stream().anyMatch(h ->
Objects.nonNull(h.getStartAt()) &&
!h.getStartAt().isAfter(end) &&
(Objects.isNull(h.getEndAt()) || !h.getEndAt().isBefore(start))))
.map(TaskEntityMapper::toDomain)
.toList();
}
@Override
public List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end) {
if (emails.isEmpty()) return List.of();
return find("user.email in ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4",
emails, TaskStatus.COMPLETED, start, end)
.list().stream().map(TaskEntityMapper::toDomain).toList();
}
}

View file

@ -7,6 +7,7 @@ import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import net.beetechgroup.beetask.entities.organization.UserOrganization;
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
@ -33,11 +34,19 @@ public class OrgDashboardUseCase {
public OrgDashboardOutput execute(OrgDashboardInput input) {
authorizer.execute(input.userEmail(), input.organizationId());
List<Task> workedTasks = taskRepository.findTasksWorkedByOrgInPeriod(
input.organizationId(), input.startDate(), input.endDate()
List<UserOrganization> members = userOrganizationRepository.findByOrganizationIdAndStatus(
input.organizationId(), UserOrganizationStatus.ACTIVE
);
List<Task> finishedTasks = taskRepository.findTasksFinishedByOrgInPeriod(
input.organizationId(), input.startDate(), input.endDate()
List<String> memberEmails = members.stream()
.map(uo -> uo.getUser().getEmail())
.toList();
List<Task> workedTasks = taskRepository.findTasksWorkedByUserEmailsInPeriod(
memberEmails, input.startDate(), input.endDate()
);
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserEmailsInPeriod(
memberEmails, input.startDate(), input.endDate()
);
Map<Long, OrgProjectStats> projectStatsMap = new HashMap<>();
@ -46,15 +55,14 @@ public class OrgDashboardUseCase {
for (Task task : workedTasks) {
long minutes = calculateMinutesInPeriod(task, input.startDate(), input.endDate());
if (task.getProject() != null) {
Long projectId = task.getProject().getId();
Long projectId = Objects.nonNull(task.getProject()) ? task.getProject().getId() : null;
String projectName = Objects.nonNull(task.getProject()) ? task.getProject().getName() : "Geral";
OrgProjectStats existing = projectStatsMap.getOrDefault(projectId,
new OrgProjectStats(projectId, task.getProject().getName(), 0L));
new OrgProjectStats(projectId, projectName, 0L));
projectStatsMap.put(projectId, new OrgProjectStats(
projectId, existing.projectName(), existing.totalMinutes() + minutes));
}
if (task.getUser() != null) {
if (Objects.nonNull(task.getUser())) {
memberMinutesMap.merge(task.getUser().getEmail(), minutes, Long::sum);
}
}
@ -62,7 +70,7 @@ public class OrgDashboardUseCase {
List<OrgTopTask> topTasks = workedTasks.stream()
.map(t -> {
long minutes = calculateMinutesInPeriod(t, input.startDate(), input.endDate());
String projectName = t.getProject() != null ? t.getProject().getName() : "-";
String projectName = Objects.nonNull(t.getProject()) ? t.getProject().getName() : "Geral";
return new OrgTopTask(t.getId(), t.getTitle(), projectName, minutes);
})
.sorted(Comparator.comparingLong(OrgTopTask::totalMinutes).reversed())
@ -71,15 +79,11 @@ public class OrgDashboardUseCase {
Map<String, Long> finishedByMember = new HashMap<>();
for (Task task : finishedTasks) {
if (task.getUser() != null) {
if (Objects.nonNull(task.getUser())) {
finishedByMember.merge(task.getUser().getEmail(), 1L, Long::sum);
}
}
List<UserOrganization> members = userOrganizationRepository.findByOrganizationIdAndStatus(
input.organizationId(), UserOrganizationStatus.ACTIVE
);
List<OrgMemberStats> memberStats = members.stream()
.map(uo -> {
String email = uo.getUser().getEmail();
@ -109,15 +113,15 @@ public class OrgDashboardUseCase {
}
private boolean isWithinPeriod(TaskHistoryItem item, LocalDateTime start, LocalDateTime end) {
if (item.getStartAt() == null) return false;
if (Objects.isNull(item.getStartAt())) return false;
if (item.getStartAt().isAfter(end)) return false;
if (item.getEndAt() != null && item.getEndAt().isBefore(start)) return false;
if (Objects.nonNull(item.getEndAt()) && item.getEndAt().isBefore(start)) return false;
return true;
}
private long calculateMinutesInRange(TaskHistoryItem item, LocalDateTime start, LocalDateTime end) {
LocalDateTime effectiveStart = item.getStartAt().isBefore(start) ? start : item.getStartAt();
LocalDateTime effectiveEnd = item.getEndAt() == null || item.getEndAt().isAfter(end) ? end : item.getEndAt();
LocalDateTime effectiveEnd = Objects.isNull(item.getEndAt()) || item.getEndAt().isAfter(end) ? end : item.getEndAt();
if (effectiveStart.isAfter(effectiveEnd)) return 0;
return Duration.between(effectiveStart, effectiveEnd).toMinutes();
}

View file

@ -67,15 +67,14 @@ public class MemberDetailUseCase {
minutesPerMonth.merge(YearMonth.from(effectiveStart), minutes, Long::sum);
}
if (Objects.nonNull(task.getProject())) {
Long projectId = task.getProject().getId();
Long projectId = Objects.nonNull(task.getProject()) ? task.getProject().getId() : null;
String projectName = Objects.nonNull(task.getProject()) ? task.getProject().getName() : "Geral";
MemberDetailOutput.ProjectStats existing = projectStatsMap.getOrDefault(projectId,
new MemberDetailOutput.ProjectStats(projectId, task.getProject().getName(), 0L));
new MemberDetailOutput.ProjectStats(projectId, projectName, 0L));
projectStatsMap.put(projectId, new MemberDetailOutput.ProjectStats(
projectId, existing.projectName(), existing.totalMinutes() + minutes));
}
}
}
List<MemberDetailOutput.PeriodStats> periodStats = new ArrayList<>();

View file

@ -16,4 +16,6 @@ public interface TaskRepository {
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksWorkedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
}

View file

@ -294,7 +294,7 @@ export const MemberDetailModal: React.FC<MemberDetailModalProps> = ({
const total = data.projectStats.reduce((s, x) => s + x.totalMinutes, 0);
const pct = total > 0 ? ((p.totalMinutes / total) * 100).toFixed(0) : "0";
return (
<div key={p.projectId} className="flex items-center gap-2">
<div key={p.projectId ?? "geral"} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full shrink-0" style={{ background: CHART_COLORS[i % CHART_COLORS.length] }} />
<span className="text-xs font-bold text-text-main truncate flex-1">{p.projectName}</span>
<span className="text-[10px] font-black text-text-muted shrink-0">

View file

@ -246,7 +246,7 @@ export const OrgDashboardPage: React.FC = () => {
.map((stat, i) => {
const pct = totalMinutes > 0 ? ((stat.totalMinutes / totalMinutes) * 100).toFixed(1) : "0";
return (
<div key={stat.projectId} className="flex items-center gap-3">
<div key={stat.projectId ?? "geral"} className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ background: CHART_COLORS[i % CHART_COLORS.length] }}

View file

@ -1,7 +1,7 @@
import { apiFetch } from '../lib/api'
export interface OrgProjectStats {
projectId: number
projectId: number | null
projectName: string
totalMinutes: number
}
@ -36,7 +36,7 @@ export interface PeriodStats {
}
export interface MemberProjectStats {
projectId: number
projectId: number | null
projectName: string
totalMinutes: number
}