feat(reports): add AI_TEXT section type powered by local Ollama qwen3.5

This commit is contained in:
Gabriel Menezes 2026-11-01 12:19:58 -04:00
parent c00a5ff4b7
commit b8c23d5faa
21 changed files with 672 additions and 9 deletions

View file

@ -11,6 +11,7 @@ public class ReportSection {
private String sectionType; // 'FREE_TEXT', 'FIXED_TEXT', 'DATE', 'TASK_LIST' private String sectionType; // 'FREE_TEXT', 'FIXED_TEXT', 'DATE', 'TASK_LIST'
private String defaultValue; private String defaultValue;
private String dateFormat; private String dateFormat;
private String aiPrompt;
private Category filterCategory; private Category filterCategory;
private String filterStatus; // 'ALL', 'COMPLETED', 'IN_PROGRESS' private String filterStatus; // 'ALL', 'COMPLETED', 'IN_PROGRESS'
private Boolean includeTime; private Boolean includeTime;
@ -105,6 +106,14 @@ public class ReportSection {
this.dateFormat = dateFormat; this.dateFormat = dateFormat;
} }
public String getAiPrompt() {
return aiPrompt;
}
public void setAiPrompt(String aiPrompt) {
this.aiPrompt = aiPrompt;
}
public LocalDateTime getCreatedAt() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }

View file

@ -0,0 +1,100 @@
package net.beetechgroup.beetask.frameworks.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ApplicationScoped
public class OllamaService {
private static final Logger LOGGER = Logger.getLogger(OllamaService.class);
@ConfigProperty(name = "app.ollama.endpoint", defaultValue = "http://beea-ollama:11434")
String endpoint;
@ConfigProperty(name = "app.ollama.model", defaultValue = "qwen3.5:0.8b")
String model;
@ConfigProperty(name = "app.ollama.timeout-seconds", defaultValue = "45")
int timeoutSeconds;
@Inject
ObjectMapper objectMapper;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
public String generateSummary(String systemPrompt, String tasksContext) {
if (tasksContext == null || tasksContext.isBlank()) {
return "Nenhuma atividade registrada no período para resumo.";
}
String effectiveSystemPrompt = (systemPrompt != null && !systemPrompt.isBlank())
? systemPrompt
: "Você é um assistente corporativo. Com base na lista de tarefas realizadas pelo colaborador abaixo, elabore um resumo conciso e profissional em um ou dois parágrafos destacando as principais entregas.";
try {
Map<String, Object> payload = new HashMap<>();
payload.put("model", model);
payload.put("stream", false);
payload.put("think", false);
List<Map<String, String>> messages = List.of(
Map.of("role", "system", "content", effectiveSystemPrompt),
Map.of("role", "user", "content", tasksContext)
);
payload.put("messages", messages);
Map<String, Object> options = Map.of(
"num_ctx", 8192,
"temperature", 0.3
);
payload.put("options", options);
String requestBody = objectMapper.writeValueAsString(payload);
String url = endpoint.replaceAll("/+$", "") + "/api/chat";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(timeoutSeconds))
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
LOGGER.infof("Enviando requisição de resumo para Ollama (%s) no modelo %s...", url, model);
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
JsonNode root = objectMapper.readTree(response.body());
String content = root.path("message").path("content").asText("");
// Sanitização defensiva caso o modelo emita tags de pensamento
String sanitized = content.replaceAll("(?s)<think>.*?</think>", "").trim();
if (sanitized.startsWith("Thinking Process:")) {
sanitized = sanitized.substring(sanitized.indexOf("\n\n") + 2).trim();
}
LOGGER.infof("Resumo com IA gerado com sucesso pelo Ollama (%d caracteres)", sanitized.length());
return sanitized;
} else {
LOGGER.warnf("Ollama retornou status HTTP %d: %s", response.statusCode(), response.body());
return null;
}
} catch (Exception e) {
LOGGER.warnf("Não foi possível conectar ao Ollama para gerar resumo com IA: %s", e.getMessage());
return null;
}
}
}

View file

@ -2,7 +2,9 @@ package net.beetechgroup.beetask.frameworks.config;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces; import jakarta.enterprise.inject.Produces;
import net.beetechgroup.beetask.frameworks.ai.OllamaService;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.report.ai.GenerateAiSummaryUseCase;
import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase; import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase;
import net.beetechgroup.beetask.usecase.report.update.UpdateReportTemplateUseCase; import net.beetechgroup.beetask.usecase.report.update.UpdateReportTemplateUseCase;
import net.beetechgroup.beetask.usecase.report.delete.DeleteReportTemplateUseCase; import net.beetechgroup.beetask.usecase.report.delete.DeleteReportTemplateUseCase;
@ -86,7 +88,8 @@ public class ReportUseCaseConfig {
UserRepository userRepository, UserRepository userRepository,
StorageRepository storageRepository, StorageRepository storageRepository,
net.beetechgroup.beetask.usecase.repository.UserSignatureRepository userSignatureRepository, net.beetechgroup.beetask.usecase.repository.UserSignatureRepository userSignatureRepository,
AuthorizeOrganizationMemberUseCase authorizer AuthorizeOrganizationMemberUseCase authorizer,
OllamaService ollamaService
) { ) {
return new GenerateReportUseCase( return new GenerateReportUseCase(
reportTemplateRepository, reportTemplateRepository,
@ -97,7 +100,25 @@ public class ReportUseCaseConfig {
userRepository, userRepository,
storageRepository, storageRepository,
userSignatureRepository, userSignatureRepository,
authorizer authorizer,
ollamaService
);
}
@Produces
public GenerateAiSummaryUseCase generateAiSummaryUseCase(
ReportTemplateRepository reportTemplateRepository,
TaskRepository taskRepository,
UserRepository userRepository,
AuthorizeOrganizationMemberUseCase authorizer,
OllamaService ollamaService
) {
return new GenerateAiSummaryUseCase(
reportTemplateRepository,
taskRepository,
userRepository,
authorizer,
ollamaService
); );
} }

View file

@ -38,6 +38,9 @@ public class ReportSectionEntity {
@Column(name = "date_format", length = 100) @Column(name = "date_format", length = 100)
private String dateFormat; private String dateFormat;
@Column(name = "ai_prompt", columnDefinition = "TEXT")
private String aiPrompt;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "filter_category_id") @JoinColumn(name = "filter_category_id")
private CategoryEntity filterCategory; private CategoryEntity filterCategory;
@ -142,6 +145,14 @@ public class ReportSectionEntity {
this.dateFormat = dateFormat; this.dateFormat = dateFormat;
} }
public String getAiPrompt() {
return aiPrompt;
}
public void setAiPrompt(String aiPrompt) {
this.aiPrompt = aiPrompt;
}
public LocalDateTime getCreatedAt() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }

View file

@ -54,6 +54,7 @@ public class ReportEntityMapper {
entity.setSectionType(domain.getSectionType()); entity.setSectionType(domain.getSectionType());
entity.setDefaultValue(domain.getDefaultValue()); entity.setDefaultValue(domain.getDefaultValue());
entity.setDateFormat(domain.getDateFormat()); entity.setDateFormat(domain.getDateFormat());
entity.setAiPrompt(domain.getAiPrompt());
entity.setFilterStatus(domain.getFilterStatus()); entity.setFilterStatus(domain.getFilterStatus());
entity.setIncludeTime(domain.getIncludeTime()); entity.setIncludeTime(domain.getIncludeTime());
entity.setOrderIndex(domain.getOrderIndex()); entity.setOrderIndex(domain.getOrderIndex());
@ -108,6 +109,7 @@ public class ReportEntityMapper {
domain.setSectionType(entity.getSectionType()); domain.setSectionType(entity.getSectionType());
domain.setDefaultValue(entity.getDefaultValue()); domain.setDefaultValue(entity.getDefaultValue());
domain.setDateFormat(entity.getDateFormat()); domain.setDateFormat(entity.getDateFormat());
domain.setAiPrompt(entity.getAiPrompt());
domain.setFilterStatus(entity.getFilterStatus()); domain.setFilterStatus(entity.getFilterStatus());
domain.setIncludeTime(entity.getIncludeTime()); domain.setIncludeTime(entity.getIncludeTime());
domain.setOrderIndex(entity.getOrderIndex()); domain.setOrderIndex(entity.getOrderIndex());

View file

@ -0,0 +1,11 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.report;
import java.time.LocalDate;
public class GenerateAiSummaryRequest {
public Long templateId;
public String sectionKey;
public LocalDate startDate;
public LocalDate endDate;
public String customPrompt;
}

View file

@ -0,0 +1,11 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.report;
public class GenerateAiSummaryResponse {
public String summary;
public GenerateAiSummaryResponse() {}
public GenerateAiSummaryResponse(String summary) {
this.summary = summary;
}
}

View file

@ -24,6 +24,7 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import net.beetechgroup.beetask.entities.report.GeneratedReport; import net.beetechgroup.beetask.entities.report.GeneratedReport;
import net.beetechgroup.beetask.usecase.report.ReportTemplateOutput; import net.beetechgroup.beetask.usecase.report.ReportTemplateOutput;
import net.beetechgroup.beetask.usecase.report.ai.GenerateAiSummaryUseCase;
import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateInput; import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateInput;
import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase; import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase;
import net.beetechgroup.beetask.usecase.report.update.UpdateReportTemplateInput; import net.beetechgroup.beetask.usecase.report.update.UpdateReportTemplateInput;
@ -62,6 +63,9 @@ public class ReportController {
@Inject @Inject
GenerateReportUseCase generateReportUseCase; GenerateReportUseCase generateReportUseCase;
@Inject
GenerateAiSummaryUseCase generateAiSummaryUseCase;
@Inject @Inject
ListGeneratedReportsUseCase listGeneratedReportsUseCase; ListGeneratedReportsUseCase listGeneratedReportsUseCase;
@ -227,6 +231,26 @@ public class ReportController {
.build(); .build();
} }
@POST
@Path("/ai-summary")
@Operation(summary = "Generate AI summary for report section", description = "Generates text summary for tasks in period using Ollama")
public GenerateAiSummaryResponse generateAiSummary(
@PathParam("orgId") Long orgId,
GenerateAiSummaryRequest req
) {
String email = securityIdentity.getPrincipal().getName();
String summary = generateAiSummaryUseCase.execute(
email,
orgId,
req.templateId,
req.sectionKey,
req.startDate,
req.endDate,
req.customPrompt
);
return new GenerateAiSummaryResponse(summary);
}
@GET @GET
@Path("/history") @Path("/history")
@Operation(summary = "List generated reports history", description = "Lists reports generated in this organization") @Operation(summary = "List generated reports history", description = "Lists reports generated in this organization")

View file

@ -48,6 +48,7 @@ public class ReportMapper {
section.getSectionType(), section.getSectionType(),
section.getDefaultValue(), section.getDefaultValue(),
section.getDateFormat(), section.getDateFormat(),
section.getAiPrompt(),
section.getFilterCategory() != null ? section.getFilterCategory().getId() : null, section.getFilterCategory() != null ? section.getFilterCategory().getId() : null,
section.getFilterCategory() != null ? section.getFilterCategory().getName() : null, section.getFilterCategory() != null ? section.getFilterCategory().getName() : null,
section.getFilterStatus(), section.getFilterStatus(),

View file

@ -9,6 +9,7 @@ public record ReportSectionOutput(
String sectionType, String sectionType,
String defaultValue, String defaultValue,
String dateFormat, String dateFormat,
String aiPrompt,
Long filterCategoryId, Long filterCategoryId,
String filterCategoryName, String filterCategoryName,
String filterStatus, String filterStatus,

View file

@ -0,0 +1,175 @@
package net.beetechgroup.beetask.usecase.report.ai;
import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.entities.report.ReportSection;
import net.beetechgroup.beetask.entities.report.ReportTemplate;
import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
import net.beetechgroup.beetask.frameworks.ai.OllamaService;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.ReportTemplateRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
import org.jboss.logging.Logger;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public class GenerateAiSummaryUseCase {
private static final Logger LOGGER = Logger.getLogger(GenerateAiSummaryUseCase.class);
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy");
private final ReportTemplateRepository reportTemplateRepository;
private final TaskRepository taskRepository;
private final UserRepository userRepository;
private final AuthorizeOrganizationMemberUseCase authorizer;
private final OllamaService ollamaService;
public GenerateAiSummaryUseCase(
ReportTemplateRepository reportTemplateRepository,
TaskRepository taskRepository,
UserRepository userRepository,
AuthorizeOrganizationMemberUseCase authorizer,
OllamaService ollamaService
) {
this.reportTemplateRepository = reportTemplateRepository;
this.taskRepository = taskRepository;
this.userRepository = userRepository;
this.authorizer = authorizer;
this.ollamaService = ollamaService;
}
public String execute(
String userEmail,
Long organizationId,
Long templateId,
String sectionKey,
LocalDate startDate,
LocalDate endDate,
String customPrompt
) {
authorizer.execute(userEmail, organizationId);
User user = userRepository.findByEmail(userEmail)
.orElseThrow(() -> new IllegalArgumentException("Usuário não encontrado: " + userEmail));
LocalDateTime startDt = startDate != null ? startDate.atStartOfDay() : LocalDate.now().withDayOfMonth(1).atStartOfDay();
LocalDateTime endDt = endDate != null ? endDate.atTime(23, 59, 59) : LocalDate.now().atTime(23, 59, 59);
String systemPrompt = customPrompt;
Long filterCategoryId = null;
if (templateId != null) {
ReportTemplate template = reportTemplateRepository.findReportTemplateById(templateId).orElse(null);
if (template != null && template.getSections() != null && sectionKey != null) {
for (ReportSection s : template.getSections()) {
if (sectionKey.equalsIgnoreCase(s.getSectionKey())) {
if (systemPrompt == null || systemPrompt.isBlank()) {
systemPrompt = s.getAiPrompt();
}
if (s.getFilterCategory() != null) {
filterCategoryId = s.getFilterCategory().getId();
}
break;
}
}
}
}
// Carregar tarefas do usuário no período
List<Task> workedTasks = taskRepository.findTasksWorkedByUserInPeriod(userEmail, startDt, endDt, organizationId);
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserInPeriod(userEmail, startDt, endDt);
Map<Long, Task> uniqueTasks = new LinkedHashMap<>();
for (Task t : workedTasks) {
if (matchesOrg(t, organizationId)) {
uniqueTasks.put(t.getId(), t);
}
}
for (Task t : finishedTasks) {
if (matchesOrg(t, organizationId)) {
uniqueTasks.put(t.getId(), t);
}
}
List<Task> allUserTasks = taskRepository.findTasksByUser(userEmail);
for (Task t : allUserTasks) {
if (matchesOrg(t, organizationId) && hasActivityInPeriod(t, startDt, endDt)) {
uniqueTasks.put(t.getId(), t);
}
}
List<Task> taskList = new ArrayList<>(uniqueTasks.values());
// Filtrar por categoria se aplicável
if (filterCategoryId != null) {
final Long finalCatId = filterCategoryId;
taskList = taskList.stream()
.filter(t -> t.getCategory() != null && Objects.equals(t.getCategory().getId(), finalCatId))
.toList();
}
String tasksContext = buildTasksContext(taskList, startDate, endDate);
LOGGER.infof("Gerando resumo com IA para %s (%d tarefas)...", userEmail, taskList.size());
String summary = ollamaService != null ? ollamaService.generateSummary(systemPrompt, tasksContext) : null;
return summary != null ? summary : "Não foi possível gerar o resumo com IA neste momento.";
}
private boolean matchesOrg(Task t, Long orgId) {
return t.getOrganization() == null || Objects.equals(t.getOrganization().getId(), orgId);
}
private boolean hasActivityInPeriod(Task t, LocalDateTime startDt, LocalDateTime endDt) {
if (t.getFinishedAt() != null && !t.getFinishedAt().isBefore(startDt) && !t.getFinishedAt().isAfter(endDt)) {
return true;
}
if (t.getHistory() != null) {
for (TaskHistoryItem h : t.getHistory()) {
if (h.getStartAt() != null && !h.getStartAt().isAfter(endDt)) {
LocalDateTime itemEnd = h.getEndAt() != null ? h.getEndAt() : LocalDateTime.now();
if (!itemEnd.isBefore(startDt)) {
return true;
}
}
}
}
return false;
}
private String buildTasksContext(List<Task> tasks, LocalDate start, LocalDate end) {
if (tasks == null || tasks.isEmpty()) return "";
StringBuilder sb = new StringBuilder();
sb.append("Tarefas do período (")
.append(start != null ? start.format(DATE_FORMATTER) : "")
.append(" a ")
.append(end != null ? end.format(DATE_FORMATTER) : "")
.append("):\n");
for (int i = 0; i < tasks.size(); i++) {
Task t = tasks.get(i);
sb.append(i + 1).append(". ");
if (t.getCategory() != null) {
sb.append("[").append(t.getCategory().getName()).append("] ");
}
sb.append(t.getTitle());
if (t.getProject() != null) {
sb.append(" (Projeto: ").append(t.getProject().getName()).append(")");
}
if (t.getDescription() != null && !t.getDescription().isBlank()) {
String cleanDesc = t.getDescription().replaceAll("<[^>]*>", " ").replaceAll("\\s+", " ").trim();
if (!cleanDesc.isEmpty()) {
sb.append(" - ").append(cleanDesc);
}
}
sb.append("\n");
}
return sb.toString();
}
}

View file

@ -24,6 +24,7 @@ public record CreateReportTemplateInput(
String sectionType, String sectionType,
String defaultValue, String defaultValue,
String dateFormat, String dateFormat,
String aiPrompt,
Long filterCategoryId, Long filterCategoryId,
String filterStatus, String filterStatus,
Boolean includeTime, Boolean includeTime,

View file

@ -109,6 +109,7 @@ public class CreateReportTemplateUseCase {
section.setSectionType(sInput.sectionType() != null ? sInput.sectionType() : "FREE_TEXT"); section.setSectionType(sInput.sectionType() != null ? sInput.sectionType() : "FREE_TEXT");
section.setDefaultValue(sInput.defaultValue() != null ? sInput.defaultValue().trim() : null); section.setDefaultValue(sInput.defaultValue() != null ? sInput.defaultValue().trim() : null);
section.setDateFormat(sInput.dateFormat() != null ? sInput.dateFormat().trim() : null); section.setDateFormat(sInput.dateFormat() != null ? sInput.dateFormat().trim() : null);
section.setAiPrompt(sInput.aiPrompt() != null ? sInput.aiPrompt().trim() : null);
section.setFilterStatus(sInput.filterStatus() != null ? sInput.filterStatus() : "ALL"); section.setFilterStatus(sInput.filterStatus() != null ? sInput.filterStatus() : "ALL");
section.setIncludeTime(sInput.includeTime() != null ? sInput.includeTime() : true); section.setIncludeTime(sInput.includeTime() != null ? sInput.includeTime() : true);
section.setOrderIndex(sInput.orderIndex() != null ? sInput.orderIndex() : order++); section.setOrderIndex(sInput.orderIndex() != null ? sInput.orderIndex() : order++);

View file

@ -51,6 +51,7 @@ public class GenerateReportUseCase {
private final StorageRepository storageRepository; private final StorageRepository storageRepository;
private final UserSignatureRepository userSignatureRepository; private final UserSignatureRepository userSignatureRepository;
private final AuthorizeOrganizationMemberUseCase authorizer; private final AuthorizeOrganizationMemberUseCase authorizer;
private final net.beetechgroup.beetask.frameworks.ai.OllamaService ollamaService;
public GenerateReportUseCase( public GenerateReportUseCase(
ReportTemplateRepository reportTemplateRepository, ReportTemplateRepository reportTemplateRepository,
@ -61,7 +62,8 @@ public class GenerateReportUseCase {
UserRepository userRepository, UserRepository userRepository,
StorageRepository storageRepository, StorageRepository storageRepository,
UserSignatureRepository userSignatureRepository, UserSignatureRepository userSignatureRepository,
AuthorizeOrganizationMemberUseCase authorizer AuthorizeOrganizationMemberUseCase authorizer,
net.beetechgroup.beetask.frameworks.ai.OllamaService ollamaService
) { ) {
this.reportTemplateRepository = reportTemplateRepository; this.reportTemplateRepository = reportTemplateRepository;
this.generatedReportRepository = generatedReportRepository; this.generatedReportRepository = generatedReportRepository;
@ -72,6 +74,7 @@ public class GenerateReportUseCase {
this.storageRepository = storageRepository; this.storageRepository = storageRepository;
this.userSignatureRepository = userSignatureRepository; this.userSignatureRepository = userSignatureRepository;
this.authorizer = authorizer; this.authorizer = authorizer;
this.ollamaService = ollamaService;
} }
public GenerateReportOutput execute(GenerateReportInput input) { public GenerateReportOutput execute(GenerateReportInput input) {
@ -252,6 +255,20 @@ public class GenerateReportUseCase {
if (!variables.containsKey(section.getSectionKey())) { if (!variables.containsKey(section.getSectionKey())) {
variables.put(section.getSectionKey(), ""); variables.put(section.getSectionKey(), "");
} }
} else if ("AI_TEXT".equalsIgnoreCase(section.getSectionType()) || "AI_SUMMARY".equalsIgnoreCase(section.getSectionType())) {
String existing = variables.get(section.getSectionKey());
if (existing == null || existing.isBlank()) {
List<Task> aiTasks = taskList;
if (section.getFilterCategory() != null) {
Long catId = section.getFilterCategory().getId();
aiTasks = taskList.stream()
.filter(t -> t.getCategory() != null && Objects.equals(t.getCategory().getId(), catId))
.toList();
}
String ctx = buildAiTasksContext(aiTasks, input.startDate(), input.endDate());
String summary = ollamaService != null ? ollamaService.generateSummary(section.getAiPrompt(), ctx) : null;
variables.put(section.getSectionKey(), summary != null ? summary : "");
}
} }
} }
} }
@ -504,4 +521,34 @@ public class GenerateReportUseCase {
default -> strSlashStart + " até " + strSlashEnd; default -> strSlashStart + " até " + strSlashEnd;
}; };
} }
private String buildAiTasksContext(List<Task> tasks, LocalDate start, LocalDate end) {
if (tasks == null || tasks.isEmpty()) return "";
StringBuilder sb = new StringBuilder();
sb.append("Tarefas do período (")
.append(start != null ? start.format(DATE_FORMATTER) : "")
.append(" a ")
.append(end != null ? end.format(DATE_FORMATTER) : "")
.append("):\n");
for (int i = 0; i < tasks.size(); i++) {
Task t = tasks.get(i);
sb.append(i + 1).append(". ");
if (t.getCategory() != null) {
sb.append("[").append(t.getCategory().getName()).append("] ");
}
sb.append(t.getTitle());
if (t.getProject() != null) {
sb.append(" (Projeto: ").append(t.getProject().getName()).append(")");
}
if (t.getDescription() != null && !t.getDescription().isBlank()) {
String cleanDesc = t.getDescription().replaceAll("<[^>]*>", " ").replaceAll("\\s+", " ").trim();
if (!cleanDesc.isEmpty()) {
sb.append(" - ").append(cleanDesc);
}
}
sb.append("\n");
}
return sb.toString();
}
} }

View file

@ -97,6 +97,7 @@ public class UpdateReportTemplateUseCase {
section.setSectionType(sInput.sectionType() != null ? sInput.sectionType() : "FREE_TEXT"); section.setSectionType(sInput.sectionType() != null ? sInput.sectionType() : "FREE_TEXT");
section.setDefaultValue(sInput.defaultValue() != null ? sInput.defaultValue().trim() : null); section.setDefaultValue(sInput.defaultValue() != null ? sInput.defaultValue().trim() : null);
section.setDateFormat(sInput.dateFormat() != null ? sInput.dateFormat().trim() : null); section.setDateFormat(sInput.dateFormat() != null ? sInput.dateFormat().trim() : null);
section.setAiPrompt(sInput.aiPrompt() != null ? sInput.aiPrompt().trim() : null);
section.setFilterStatus(sInput.filterStatus() != null ? sInput.filterStatus() : "ALL"); section.setFilterStatus(sInput.filterStatus() != null ? sInput.filterStatus() : "ALL");
section.setIncludeTime(sInput.includeTime() != null ? sInput.includeTime() : true); section.setIncludeTime(sInput.includeTime() != null ? sInput.includeTime() : true);
section.setOrderIndex(sInput.orderIndex() != null ? sInput.orderIndex() : order++); section.setOrderIndex(sInput.orderIndex() != null ? sInput.orderIndex() : order++);

View file

@ -0,0 +1 @@
ALTER TABLE report_sections ADD COLUMN IF NOT EXISTS ai_prompt TEXT;

View file

@ -29,9 +29,10 @@ interface CreateTemplateModalProps {
interface SectionFormItem { interface SectionFormItem {
sectionKey: string sectionKey: string
title: string title: string
sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE' sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE' | 'AI_TEXT'
defaultValue?: string defaultValue?: string
dateFormat?: string dateFormat?: string
aiPrompt?: string
filterCategoryId?: number filterCategoryId?: number
filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS'
includeTime: boolean includeTime: boolean
@ -128,6 +129,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
sectionType: s.sectionType, sectionType: s.sectionType,
defaultValue: s.defaultValue ? s.defaultValue.trim() : '', defaultValue: s.defaultValue ? s.defaultValue.trim() : '',
dateFormat: s.sectionType === 'DATE' ? (s.dateFormat || 'DD/MM/AAAA') : undefined, dateFormat: s.sectionType === 'DATE' ? (s.dateFormat || 'DD/MM/AAAA') : undefined,
aiPrompt: s.sectionType === 'AI_TEXT' ? (s.aiPrompt?.trim() || undefined) : undefined,
filterCategoryId: s.filterCategoryId, filterCategoryId: s.filterCategoryId,
filterStatus: s.filterStatus || 'ALL', filterStatus: s.filterStatus || 'ALL',
includeTime: s.includeTime ?? true, includeTime: s.includeTime ?? true,
@ -377,7 +379,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
value={section.sectionType} value={section.sectionType}
onChange={(val) => onChange={(val) =>
handleUpdateSection(idx, { handleUpdateSection(idx, {
sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE', sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE' | 'AI_TEXT',
}) })
} }
options={[ options={[
@ -385,12 +387,67 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
{ value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' }, { value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' },
{ value: 'DATE', label: 'Data (Com formato personalizado)' }, { value: 'DATE', label: 'Data (Com formato personalizado)' },
{ value: 'SIGNATURE', label: '✍️ Assinatura Digital (Assinatura do usuário)' }, { value: 'SIGNATURE', label: '✍️ Assinatura Digital (Assinatura do usuário)' },
{ value: 'AI_TEXT', label: '✨ Resumo / Texto com IA (Ollama)' },
{ value: 'TASK_LIST', label: 'Lista de Tarefas do Período' }, { value: 'TASK_LIST', label: 'Lista de Tarefas do Período' },
]} ]}
/> />
</div> </div>
</div> </div>
{/* Configuração de Campo de Tipo IA */}
{section.sectionType === 'AI_TEXT' && (
<div className="pt-2 border-t border-border-soft/60 space-y-3 animate-in fade-in duration-150">
<div className="p-3 rounded-xl bg-purple-500/10 border border-purple-500/20 text-[11px] text-foreground/80 flex items-start gap-2.5">
<Sparkles className="w-4 h-4 text-purple-600 shrink-0 mt-0.5" />
<div>
<p className="font-bold text-purple-700 dark:text-purple-400">Geração Inteligente com IA Local (Ollama / Qwen)</p>
<p className="text-muted mt-0.5">
A IA lerá as tarefas do colaborador no período (título, descrição, categoria, projeto) e gerará o resumo conforme o prompt configurado abaixo. Na emissão do relatório, o usuário poderá clicar em <strong>"Gerar com IA"</strong> e editar o resultado antes de baixar o documento.
</p>
</div>
</div>
<div>
<label className="text-[10px] font-semibold text-muted block mb-1">
Instruções para a IA (Prompt personalizado)
</label>
<textarea
rows={3}
value={section.aiPrompt || ''}
onChange={(e) => handleUpdateSection(idx, { aiPrompt: e.target.value })}
placeholder="Ex.: Resuma as atividades realizadas pelo colaborador de forma clara, técnica e concisa, destacando as entregas principais. Responda em português sem introduções ou saudações."
className="w-full px-2.5 py-1.5 rounded-lg border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-purple-500 font-mono shadow-xs resize-y"
/>
<span className="text-[9px] text-muted block mt-0.5">
Deixe em branco para usar a instrução técnica padrão do sistema.
</span>
</div>
<div>
<label className="text-[10px] font-semibold text-muted block mb-1">
Filtrar tarefas enviadas para a IA por Categoria (Opcional)
</label>
<CustomSelect
size="sm"
value={section.filterCategoryId ?? 0}
onChange={(val) =>
handleUpdateSection(idx, {
filterCategoryId: Number(val) === 0 ? undefined : Number(val),
})
}
options={[
{ value: 0, label: 'Todas as Categorias' },
...categories.map((c) => ({
value: c.id,
label: c.name,
color: c.color,
})),
]}
/>
</div>
</div>
)}
{/* Configuração de Campo de Tipo ASSINATURA */} {/* Configuração de Campo de Tipo ASSINATURA */}
{section.sectionType === 'SIGNATURE' && ( {section.sectionType === 'SIGNATURE' && (
<div className="p-3 rounded-xl bg-brand/5 border border-brand/15 text-[11px] text-foreground/80 flex items-start gap-2.5 animate-in fade-in duration-150"> <div className="p-3 rounded-xl bg-brand/5 border border-brand/15 text-[11px] text-foreground/80 flex items-start gap-2.5 animate-in fade-in duration-150">

View file

@ -31,9 +31,10 @@ interface EditTemplateModalProps {
interface SectionFormItem { interface SectionFormItem {
sectionKey: string sectionKey: string
title: string title: string
sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE' sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE' | 'AI_TEXT'
defaultValue?: string defaultValue?: string
dateFormat?: string dateFormat?: string
aiPrompt?: string
filterCategoryId?: number filterCategoryId?: number
filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS'
includeTime: boolean includeTime: boolean
@ -71,6 +72,7 @@ export const EditTemplateModal: React.FC<EditTemplateModalProps> = ({
sectionType: s.sectionType, sectionType: s.sectionType,
defaultValue: s.defaultValue || '', defaultValue: s.defaultValue || '',
dateFormat: s.dateFormat || 'DD/MM/AAAA', dateFormat: s.dateFormat || 'DD/MM/AAAA',
aiPrompt: s.aiPrompt || '',
filterCategoryId: s.filterCategoryId, filterCategoryId: s.filterCategoryId,
filterStatus: s.filterStatus || 'ALL', filterStatus: s.filterStatus || 'ALL',
includeTime: s.includeTime ?? true, includeTime: s.includeTime ?? true,
@ -146,6 +148,7 @@ export const EditTemplateModal: React.FC<EditTemplateModalProps> = ({
sectionType: s.sectionType, sectionType: s.sectionType,
defaultValue: s.defaultValue ? s.defaultValue.trim() : '', defaultValue: s.defaultValue ? s.defaultValue.trim() : '',
dateFormat: s.sectionType === 'DATE' ? (s.dateFormat || 'DD/MM/AAAA') : undefined, dateFormat: s.sectionType === 'DATE' ? (s.dateFormat || 'DD/MM/AAAA') : undefined,
aiPrompt: s.sectionType === 'AI_TEXT' ? (s.aiPrompt?.trim() || undefined) : undefined,
filterCategoryId: s.filterCategoryId, filterCategoryId: s.filterCategoryId,
filterStatus: s.filterStatus || 'ALL', filterStatus: s.filterStatus || 'ALL',
includeTime: s.includeTime ?? true, includeTime: s.includeTime ?? true,
@ -399,7 +402,7 @@ export const EditTemplateModal: React.FC<EditTemplateModalProps> = ({
value={section.sectionType} value={section.sectionType}
onChange={(val) => onChange={(val) =>
handleUpdateSection(idx, { handleUpdateSection(idx, {
sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE', sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE' | 'AI_TEXT',
}) })
} }
options={[ options={[
@ -407,12 +410,67 @@ export const EditTemplateModal: React.FC<EditTemplateModalProps> = ({
{ value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' }, { value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' },
{ value: 'DATE', label: 'Data (Com formato personalizado)' }, { value: 'DATE', label: 'Data (Com formato personalizado)' },
{ value: 'SIGNATURE', label: '✍️ Assinatura Digital (Assinatura do usuário)' }, { value: 'SIGNATURE', label: '✍️ Assinatura Digital (Assinatura do usuário)' },
{ value: 'AI_TEXT', label: '✨ Resumo / Texto com IA (Ollama)' },
{ value: 'TASK_LIST', label: 'Lista de Tarefas do Período' }, { value: 'TASK_LIST', label: 'Lista de Tarefas do Período' },
]} ]}
/> />
</div> </div>
</div> </div>
{/* Configuração de Campo de Tipo IA */}
{section.sectionType === 'AI_TEXT' && (
<div className="pt-2 border-t border-border-soft/60 space-y-3 animate-in fade-in duration-150">
<div className="p-3 rounded-xl bg-purple-500/10 border border-purple-500/20 text-[11px] text-foreground/80 flex items-start gap-2.5">
<Sparkles className="w-4 h-4 text-purple-600 shrink-0 mt-0.5" />
<div>
<p className="font-bold text-purple-700 dark:text-purple-400">Geração Inteligente com IA Local (Ollama / Qwen)</p>
<p className="text-muted mt-0.5">
A IA lerá as tarefas do colaborador no período (título, descrição, categoria, projeto) e gerará o resumo conforme o prompt configurado abaixo. Na emissão do relatório, o usuário poderá clicar em <strong>"Gerar com IA"</strong> e editar o resultado antes de baixar o documento.
</p>
</div>
</div>
<div>
<label className="text-[10px] font-semibold text-muted block mb-1">
Instruções para a IA (Prompt personalizado)
</label>
<textarea
rows={3}
value={section.aiPrompt || ''}
onChange={(e) => handleUpdateSection(idx, { aiPrompt: e.target.value })}
placeholder="Ex.: Resuma as atividades realizadas pelo colaborador de forma clara, técnica e concisa, destacando as entregas principais. Responda em português sem introduções ou saudações."
className="w-full px-2.5 py-1.5 rounded-lg border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-purple-500 font-mono shadow-xs resize-y"
/>
<span className="text-[9px] text-muted block mt-0.5">
Deixe em branco para usar a instrução técnica padrão do sistema.
</span>
</div>
<div>
<label className="text-[10px] font-semibold text-muted block mb-1">
Filtrar tarefas enviadas para a IA por Categoria (Opcional)
</label>
<CustomSelect
size="sm"
value={section.filterCategoryId ?? 0}
onChange={(val) =>
handleUpdateSection(idx, {
filterCategoryId: Number(val) === 0 ? undefined : Number(val),
})
}
options={[
{ value: 0, label: 'Todas as Categorias' },
...categories.map((c) => ({
value: c.id,
label: c.name,
color: c.color,
})),
]}
/>
</div>
</div>
)}
{/* Configuração de Campo de Tipo ASSINATURA */} {/* Configuração de Campo de Tipo ASSINATURA */}
{section.sectionType === 'SIGNATURE' && ( {section.sectionType === 'SIGNATURE' && (
<div className="p-3 rounded-xl bg-brand/5 border border-brand/15 text-[11px] text-foreground/80 flex items-start gap-2.5 animate-in fade-in duration-150"> <div className="p-3 rounded-xl bg-brand/5 border border-brand/15 text-[11px] text-foreground/80 flex items-start gap-2.5 animate-in fade-in duration-150">

View file

@ -11,9 +11,10 @@ import {
Lock, Lock,
Tag, Tag,
PenTool, PenTool,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import { CustomSelect } from '../ui/Select' import { CustomSelect } from '../ui/Select'
import { reportService, type ReportTemplateOutput } from '../../services/reportService' import { reportService, type ReportTemplateOutput, type ReportSectionOutput } from '../../services/reportService'
import type { Project } from '../../types/task' import type { Project } from '../../types/task'
interface GenerateReportModalProps { interface GenerateReportModalProps {
@ -52,6 +53,8 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
const [endDate, setEndDate] = useState(defaultDates.end) const [endDate, setEndDate] = useState(defaultDates.end)
const [periodPreset, setPeriodPreset] = useState<'current_month' | 'last_month' | 'custom'>('current_month') const [periodPreset, setPeriodPreset] = useState<'current_month' | 'last_month' | 'custom'>('current_month')
const [customFields, setCustomFields] = useState<Record<string, string>>({}) const [customFields, setCustomFields] = useState<Record<string, string>>({})
const [aiLoading, setAiLoading] = useState<Record<string, boolean>>({})
const [aiError, setAiError] = useState<Record<string, string | null>>({})
const [generating, setGenerating] = useState(false) const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -114,6 +117,37 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
return selectedTemplate.sections.filter((s) => s.sectionType === 'SIGNATURE') return selectedTemplate.sections.filter((s) => s.sectionType === 'SIGNATURE')
}, [selectedTemplate]) }, [selectedTemplate])
// Seções com Inteligência Artificial (Ollama / Qwen)
const aiSections = useMemo(() => {
if (!selectedTemplate) return []
return selectedTemplate.sections.filter((s) => s.sectionType === 'AI_TEXT')
}, [selectedTemplate])
const handleGenerateAi = async (section: ReportSectionOutput) => {
if (!selectedTemplateId) return
try {
setAiLoading((prev) => ({ ...prev, [section.sectionKey]: true }))
setAiError((prev) => ({ ...prev, [section.sectionKey]: null }))
const res = await reportService.generateAiSummary(orgId, {
templateId: selectedTemplateId,
sectionKey: section.sectionKey,
startDate,
endDate,
})
if (res.summary) {
setCustomFields((prev) => ({ ...prev, [section.sectionKey]: res.summary }))
}
} catch (err: unknown) {
console.error(err)
setAiError((prev) => ({
...prev,
[section.sectionKey]: err instanceof Error ? err.message : 'Falha ao gerar resumo com IA',
}))
} finally {
setAiLoading((prev) => ({ ...prev, [section.sectionKey]: false }))
}
}
// Preset Handlers // Preset Handlers
const handlePresetChange = (preset: 'current_month' | 'last_month' | 'custom') => { const handlePresetChange = (preset: 'current_month' | 'last_month' | 'custom') => {
setPeriodPreset(preset) setPeriodPreset(preset)
@ -483,6 +517,81 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
</div> </div>
)} )}
{/* Seções com Inteligência Artificial (Ollama / Qwen) */}
{aiSections.length > 0 && (
<div className="space-y-4">
<div className="border-b border-border-soft pb-1">
<h4 className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-purple-600" />
Campos com Inteligência Artificial
</h4>
<p className="text-[11px] text-muted">
Gere o resumo de atividades com IA local (Ollama). Você pode revisar e editar o texto livremente antes de gerar o Word.
</p>
</div>
{aiSections.map((sec) => {
const isLoading = Boolean(aiLoading[sec.sectionKey])
const hasValue = Boolean(customFields[sec.sectionKey]?.trim())
const errorMsg = aiError[sec.sectionKey]
return (
<div
key={sec.sectionKey}
className="p-4 rounded-2xl border border-purple-500/20 bg-purple-500/5 space-y-3"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-foreground">{sec.title}</span>
<code className="text-[10px] text-purple-700 dark:text-purple-300 font-mono bg-purple-500/10 px-1.5 py-0.5 rounded">
{`{{${sec.sectionKey}}}`}
</code>
</div>
<button
type="button"
onClick={() => handleGenerateAi(sec)}
disabled={isLoading}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-purple-600 hover:bg-purple-700 text-white text-[11px] font-bold shadow-xs transition-all cursor-pointer disabled:opacity-50"
>
{isLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Gerando resumo...</span>
</>
) : (
<>
<Sparkles className="w-3.5 h-3.5" />
<span>{hasValue ? 'Regerar com IA' : 'Gerar com IA'}</span>
</>
)}
</button>
</div>
{errorMsg && (
<div className="p-2.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-700 dark:text-red-400 text-xs flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
<span>{errorMsg}</span>
</div>
)}
<textarea
rows={4}
value={customFields[sec.sectionKey] || ''}
onChange={(e) => handleCustomFieldChange(sec.sectionKey, e.target.value)}
placeholder="Clique em 'Gerar com IA' para produzir o resumo a partir das tarefas do período, ou digite aqui..."
className="w-full p-3 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-purple-500 focus:ring-1 focus:ring-purple-500 shadow-xs resize-y"
/>
<p className="text-[10px] text-muted">
💡 Caso não clique em "Gerar com IA" e deixe este campo vazio, o backend tentará gerá-lo automaticamente ao compor o documento .DOCX.
</p>
</div>
)
})}
</div>
)}
{/* Listagens de Tarefas Configuradas no Modelo */} {/* Listagens de Tarefas Configuradas no Modelo */}
{taskListSections.length > 0 && ( {taskListSections.length > 0 && (
<div className="p-4 rounded-2xl bg-surface-raised/40 border border-border-soft space-y-2.5"> <div className="p-4 rounded-2xl bg-surface-raised/40 border border-border-soft space-y-2.5">

View file

@ -304,6 +304,12 @@ export const ReportsPage: React.FC = () => {
className="px-2 py-0.5 rounded-md text-[10px] font-mono font-semibold bg-surface-raised border border-border-soft text-foreground/80 inline-flex items-center gap-1" className="px-2 py-0.5 rounded-md text-[10px] font-mono font-semibold bg-surface-raised border border-border-soft text-foreground/80 inline-flex items-center gap-1"
> >
<span>{`{{${s.sectionKey}}}`}</span> <span>{`{{${s.sectionKey}}}`}</span>
{s.sectionType === 'AI_TEXT' && (
<span className="text-[9px] text-purple-600 dark:text-purple-400 font-sans font-bold">✨ IA</span>
)}
{s.sectionType === 'SIGNATURE' && (
<span className="text-[9px] text-brand font-sans font-bold">✍️</span>
)}
{s.sectionType === 'DATE' && ( {s.sectionType === 'DATE' && (
<span className="text-[9px] text-brand font-sans font-bold">[{s.dateFormat || 'data'}]</span> <span className="text-[9px] text-brand font-sans font-bold">[{s.dateFormat || 'data'}]</span>
)} )}

View file

@ -4,9 +4,10 @@ export interface ReportSectionOutput {
id: number id: number
sectionKey: string sectionKey: string
title: string title: string
sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE' sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE' | 'AI_TEXT'
defaultValue?: string defaultValue?: string
dateFormat?: string dateFormat?: string
aiPrompt?: string
filterCategoryId?: number filterCategoryId?: number
filterCategoryName?: string filterCategoryName?: string
filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS'
@ -62,6 +63,14 @@ export interface GenerateReportPayload {
customFields?: Record<string, string> customFields?: Record<string, string>
} }
export interface GenerateAiSummaryPayload {
templateId?: number
sectionKey?: string
startDate?: string
endDate?: string
customPrompt?: string
}
export const reportService = { export const reportService = {
async listTemplates(orgId: number, projectId?: number): Promise<ReportTemplateOutput[]> { async listTemplates(orgId: number, projectId?: number): Promise<ReportTemplateOutput[]> {
const query = projectId ? `?projectId=${projectId}` : '' const query = projectId ? `?projectId=${projectId}` : ''
@ -130,4 +139,11 @@ export const reportService = {
async listHistory(orgId: number): Promise<GeneratedReportOutput[]> { async listHistory(orgId: number): Promise<GeneratedReportOutput[]> {
return apiFetch<GeneratedReportOutput[]>(`/organizations/${orgId}/reports/history`) return apiFetch<GeneratedReportOutput[]>(`/organizations/${orgId}/reports/history`)
}, },
async generateAiSummary(orgId: number, payload: GenerateAiSummaryPayload): Promise<{ summary: string }> {
return apiFetch<{ summary: string }>(`/organizations/${orgId}/reports/ai-summary`, {
method: 'POST',
body: JSON.stringify(payload),
})
},
} }