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

View file

@ -67,13 +67,12 @@ public class MemberDetailUseCase {
minutesPerMonth.merge(YearMonth.from(effectiveStart), minutes, Long::sum); minutesPerMonth.merge(YearMonth.from(effectiveStart), minutes, Long::sum);
} }
if (Objects.nonNull(task.getProject())) { Long projectId = Objects.nonNull(task.getProject()) ? task.getProject().getId() : null;
Long projectId = task.getProject().getId(); String projectName = Objects.nonNull(task.getProject()) ? task.getProject().getName() : "Geral";
MemberDetailOutput.ProjectStats existing = projectStatsMap.getOrDefault(projectId, 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( projectStatsMap.put(projectId, new MemberDetailOutput.ProjectStats(
projectId, existing.projectName(), existing.totalMinutes() + minutes)); projectId, existing.projectName(), existing.totalMinutes() + minutes));
}
} }
} }

View file

@ -16,4 +16,6 @@ public interface TaskRepository {
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end); List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByUserIdInPeriod(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 total = data.projectStats.reduce((s, x) => s + x.totalMinutes, 0);
const pct = total > 0 ? ((p.totalMinutes / total) * 100).toFixed(0) : "0"; const pct = total > 0 ? ((p.totalMinutes / total) * 100).toFixed(0) : "0";
return ( 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] }} /> <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-xs font-bold text-text-main truncate flex-1">{p.projectName}</span>
<span className="text-[10px] font-black text-text-muted shrink-0"> <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) => { .map((stat, i) => {
const pct = totalMinutes > 0 ? ((stat.totalMinutes / totalMinutes) * 100).toFixed(1) : "0"; const pct = totalMinutes > 0 ? ((stat.totalMinutes / totalMinutes) * 100).toFixed(1) : "0";
return ( return (
<div key={stat.projectId} className="flex items-center gap-3"> <div key={stat.projectId ?? "geral"} className="flex items-center gap-3">
<div <div
className="w-3 h-3 rounded-full shrink-0" className="w-3 h-3 rounded-full shrink-0"
style={{ background: CHART_COLORS[i % CHART_COLORS.length] }} style={{ background: CHART_COLORS[i % CHART_COLORS.length] }}

View file

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