feat(reports): add AI_TEXT section type powered by local Ollama qwen3.5
This commit is contained in:
parent
c00a5ff4b7
commit
b8c23d5faa
21 changed files with 672 additions and 9 deletions
|
|
@ -11,6 +11,7 @@ public class ReportSection {
|
|||
private String sectionType; // 'FREE_TEXT', 'FIXED_TEXT', 'DATE', 'TASK_LIST'
|
||||
private String defaultValue;
|
||||
private String dateFormat;
|
||||
private String aiPrompt;
|
||||
private Category filterCategory;
|
||||
private String filterStatus; // 'ALL', 'COMPLETED', 'IN_PROGRESS'
|
||||
private Boolean includeTime;
|
||||
|
|
@ -105,6 +106,14 @@ public class ReportSection {
|
|||
this.dateFormat = dateFormat;
|
||||
}
|
||||
|
||||
public String getAiPrompt() {
|
||||
return aiPrompt;
|
||||
}
|
||||
|
||||
public void setAiPrompt(String aiPrompt) {
|
||||
this.aiPrompt = aiPrompt;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@ package net.beetechgroup.beetask.frameworks.config;
|
|||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
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.report.ai.GenerateAiSummaryUseCase;
|
||||
import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase;
|
||||
import net.beetechgroup.beetask.usecase.report.update.UpdateReportTemplateUseCase;
|
||||
import net.beetechgroup.beetask.usecase.report.delete.DeleteReportTemplateUseCase;
|
||||
|
|
@ -86,7 +88,8 @@ public class ReportUseCaseConfig {
|
|||
UserRepository userRepository,
|
||||
StorageRepository storageRepository,
|
||||
net.beetechgroup.beetask.usecase.repository.UserSignatureRepository userSignatureRepository,
|
||||
AuthorizeOrganizationMemberUseCase authorizer
|
||||
AuthorizeOrganizationMemberUseCase authorizer,
|
||||
OllamaService ollamaService
|
||||
) {
|
||||
return new GenerateReportUseCase(
|
||||
reportTemplateRepository,
|
||||
|
|
@ -97,7 +100,25 @@ public class ReportUseCaseConfig {
|
|||
userRepository,
|
||||
storageRepository,
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ public class ReportSectionEntity {
|
|||
@Column(name = "date_format", length = 100)
|
||||
private String dateFormat;
|
||||
|
||||
@Column(name = "ai_prompt", columnDefinition = "TEXT")
|
||||
private String aiPrompt;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "filter_category_id")
|
||||
private CategoryEntity filterCategory;
|
||||
|
|
@ -142,6 +145,14 @@ public class ReportSectionEntity {
|
|||
this.dateFormat = dateFormat;
|
||||
}
|
||||
|
||||
public String getAiPrompt() {
|
||||
return aiPrompt;
|
||||
}
|
||||
|
||||
public void setAiPrompt(String aiPrompt) {
|
||||
this.aiPrompt = aiPrompt;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ public class ReportEntityMapper {
|
|||
entity.setSectionType(domain.getSectionType());
|
||||
entity.setDefaultValue(domain.getDefaultValue());
|
||||
entity.setDateFormat(domain.getDateFormat());
|
||||
entity.setAiPrompt(domain.getAiPrompt());
|
||||
entity.setFilterStatus(domain.getFilterStatus());
|
||||
entity.setIncludeTime(domain.getIncludeTime());
|
||||
entity.setOrderIndex(domain.getOrderIndex());
|
||||
|
|
@ -108,6 +109,7 @@ public class ReportEntityMapper {
|
|||
domain.setSectionType(entity.getSectionType());
|
||||
domain.setDefaultValue(entity.getDefaultValue());
|
||||
domain.setDateFormat(entity.getDateFormat());
|
||||
domain.setAiPrompt(entity.getAiPrompt());
|
||||
domain.setFilterStatus(entity.getFilterStatus());
|
||||
domain.setIncludeTime(entity.getIncludeTime());
|
||||
domain.setOrderIndex(entity.getOrderIndex());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import jakarta.ws.rs.core.MediaType;
|
|||
import jakarta.ws.rs.core.Response;
|
||||
import net.beetechgroup.beetask.entities.report.GeneratedReport;
|
||||
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.CreateReportTemplateUseCase;
|
||||
import net.beetechgroup.beetask.usecase.report.update.UpdateReportTemplateInput;
|
||||
|
|
@ -62,6 +63,9 @@ public class ReportController {
|
|||
@Inject
|
||||
GenerateReportUseCase generateReportUseCase;
|
||||
|
||||
@Inject
|
||||
GenerateAiSummaryUseCase generateAiSummaryUseCase;
|
||||
|
||||
@Inject
|
||||
ListGeneratedReportsUseCase listGeneratedReportsUseCase;
|
||||
|
||||
|
|
@ -227,6 +231,26 @@ public class ReportController {
|
|||
.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
|
||||
@Path("/history")
|
||||
@Operation(summary = "List generated reports history", description = "Lists reports generated in this organization")
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ public class ReportMapper {
|
|||
section.getSectionType(),
|
||||
section.getDefaultValue(),
|
||||
section.getDateFormat(),
|
||||
section.getAiPrompt(),
|
||||
section.getFilterCategory() != null ? section.getFilterCategory().getId() : null,
|
||||
section.getFilterCategory() != null ? section.getFilterCategory().getName() : null,
|
||||
section.getFilterStatus(),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ public record ReportSectionOutput(
|
|||
String sectionType,
|
||||
String defaultValue,
|
||||
String dateFormat,
|
||||
String aiPrompt,
|
||||
Long filterCategoryId,
|
||||
String filterCategoryName,
|
||||
String filterStatus,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ public record CreateReportTemplateInput(
|
|||
String sectionType,
|
||||
String defaultValue,
|
||||
String dateFormat,
|
||||
String aiPrompt,
|
||||
Long filterCategoryId,
|
||||
String filterStatus,
|
||||
Boolean includeTime,
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ public class CreateReportTemplateUseCase {
|
|||
section.setSectionType(sInput.sectionType() != null ? sInput.sectionType() : "FREE_TEXT");
|
||||
section.setDefaultValue(sInput.defaultValue() != null ? sInput.defaultValue().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.setIncludeTime(sInput.includeTime() != null ? sInput.includeTime() : true);
|
||||
section.setOrderIndex(sInput.orderIndex() != null ? sInput.orderIndex() : order++);
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ public class GenerateReportUseCase {
|
|||
private final StorageRepository storageRepository;
|
||||
private final UserSignatureRepository userSignatureRepository;
|
||||
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||
private final net.beetechgroup.beetask.frameworks.ai.OllamaService ollamaService;
|
||||
|
||||
public GenerateReportUseCase(
|
||||
ReportTemplateRepository reportTemplateRepository,
|
||||
|
|
@ -61,7 +62,8 @@ public class GenerateReportUseCase {
|
|||
UserRepository userRepository,
|
||||
StorageRepository storageRepository,
|
||||
UserSignatureRepository userSignatureRepository,
|
||||
AuthorizeOrganizationMemberUseCase authorizer
|
||||
AuthorizeOrganizationMemberUseCase authorizer,
|
||||
net.beetechgroup.beetask.frameworks.ai.OllamaService ollamaService
|
||||
) {
|
||||
this.reportTemplateRepository = reportTemplateRepository;
|
||||
this.generatedReportRepository = generatedReportRepository;
|
||||
|
|
@ -72,6 +74,7 @@ public class GenerateReportUseCase {
|
|||
this.storageRepository = storageRepository;
|
||||
this.userSignatureRepository = userSignatureRepository;
|
||||
this.authorizer = authorizer;
|
||||
this.ollamaService = ollamaService;
|
||||
}
|
||||
|
||||
public GenerateReportOutput execute(GenerateReportInput input) {
|
||||
|
|
@ -252,6 +255,20 @@ public class GenerateReportUseCase {
|
|||
if (!variables.containsKey(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;
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ public class UpdateReportTemplateUseCase {
|
|||
section.setSectionType(sInput.sectionType() != null ? sInput.sectionType() : "FREE_TEXT");
|
||||
section.setDefaultValue(sInput.defaultValue() != null ? sInput.defaultValue().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.setIncludeTime(sInput.includeTime() != null ? sInput.includeTime() : true);
|
||||
section.setOrderIndex(sInput.orderIndex() != null ? sInput.orderIndex() : order++);
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE report_sections ADD COLUMN IF NOT EXISTS ai_prompt TEXT;
|
||||
|
|
@ -29,9 +29,10 @@ interface CreateTemplateModalProps {
|
|||
interface SectionFormItem {
|
||||
sectionKey: 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
|
||||
dateFormat?: string
|
||||
aiPrompt?: string
|
||||
filterCategoryId?: number
|
||||
filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS'
|
||||
includeTime: boolean
|
||||
|
|
@ -128,6 +129,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
|
|||
sectionType: s.sectionType,
|
||||
defaultValue: s.defaultValue ? s.defaultValue.trim() : '',
|
||||
dateFormat: s.sectionType === 'DATE' ? (s.dateFormat || 'DD/MM/AAAA') : undefined,
|
||||
aiPrompt: s.sectionType === 'AI_TEXT' ? (s.aiPrompt?.trim() || undefined) : undefined,
|
||||
filterCategoryId: s.filterCategoryId,
|
||||
filterStatus: s.filterStatus || 'ALL',
|
||||
includeTime: s.includeTime ?? true,
|
||||
|
|
@ -377,7 +379,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
|
|||
value={section.sectionType}
|
||||
onChange={(val) =>
|
||||
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={[
|
||||
|
|
@ -385,12 +387,67 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
|
|||
{ value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' },
|
||||
{ value: 'DATE', label: 'Data (Com formato personalizado)' },
|
||||
{ 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' },
|
||||
]}
|
||||
/>
|
||||
</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 */}
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -31,9 +31,10 @@ interface EditTemplateModalProps {
|
|||
interface SectionFormItem {
|
||||
sectionKey: 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
|
||||
dateFormat?: string
|
||||
aiPrompt?: string
|
||||
filterCategoryId?: number
|
||||
filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS'
|
||||
includeTime: boolean
|
||||
|
|
@ -71,6 +72,7 @@ export const EditTemplateModal: React.FC<EditTemplateModalProps> = ({
|
|||
sectionType: s.sectionType,
|
||||
defaultValue: s.defaultValue || '',
|
||||
dateFormat: s.dateFormat || 'DD/MM/AAAA',
|
||||
aiPrompt: s.aiPrompt || '',
|
||||
filterCategoryId: s.filterCategoryId,
|
||||
filterStatus: s.filterStatus || 'ALL',
|
||||
includeTime: s.includeTime ?? true,
|
||||
|
|
@ -146,6 +148,7 @@ export const EditTemplateModal: React.FC<EditTemplateModalProps> = ({
|
|||
sectionType: s.sectionType,
|
||||
defaultValue: s.defaultValue ? s.defaultValue.trim() : '',
|
||||
dateFormat: s.sectionType === 'DATE' ? (s.dateFormat || 'DD/MM/AAAA') : undefined,
|
||||
aiPrompt: s.sectionType === 'AI_TEXT' ? (s.aiPrompt?.trim() || undefined) : undefined,
|
||||
filterCategoryId: s.filterCategoryId,
|
||||
filterStatus: s.filterStatus || 'ALL',
|
||||
includeTime: s.includeTime ?? true,
|
||||
|
|
@ -399,7 +402,7 @@ export const EditTemplateModal: React.FC<EditTemplateModalProps> = ({
|
|||
value={section.sectionType}
|
||||
onChange={(val) =>
|
||||
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={[
|
||||
|
|
@ -407,12 +410,67 @@ export const EditTemplateModal: React.FC<EditTemplateModalProps> = ({
|
|||
{ value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' },
|
||||
{ value: 'DATE', label: 'Data (Com formato personalizado)' },
|
||||
{ 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' },
|
||||
]}
|
||||
/>
|
||||
</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 */}
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import {
|
|||
Lock,
|
||||
Tag,
|
||||
PenTool,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
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'
|
||||
|
||||
interface GenerateReportModalProps {
|
||||
|
|
@ -52,6 +53,8 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
|
|||
const [endDate, setEndDate] = useState(defaultDates.end)
|
||||
const [periodPreset, setPeriodPreset] = useState<'current_month' | 'last_month' | 'custom'>('current_month')
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -114,6 +117,37 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
|
|||
return selectedTemplate.sections.filter((s) => s.sectionType === 'SIGNATURE')
|
||||
}, [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
|
||||
const handlePresetChange = (preset: 'current_month' | 'last_month' | 'custom') => {
|
||||
setPeriodPreset(preset)
|
||||
|
|
@ -483,6 +517,81 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
|
|||
</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 */}
|
||||
{taskListSections.length > 0 && (
|
||||
<div className="p-4 rounded-2xl bg-surface-raised/40 border border-border-soft space-y-2.5">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<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' && (
|
||||
<span className="text-[9px] text-brand font-sans font-bold">[{s.dateFormat || 'data'}]</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ export interface ReportSectionOutput {
|
|||
id: number
|
||||
sectionKey: 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
|
||||
dateFormat?: string
|
||||
aiPrompt?: string
|
||||
filterCategoryId?: number
|
||||
filterCategoryName?: string
|
||||
filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS'
|
||||
|
|
@ -62,6 +63,14 @@ export interface GenerateReportPayload {
|
|||
customFields?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface GenerateAiSummaryPayload {
|
||||
templateId?: number
|
||||
sectionKey?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
customPrompt?: string
|
||||
}
|
||||
|
||||
export const reportService = {
|
||||
async listTemplates(orgId: number, projectId?: number): Promise<ReportTemplateOutput[]> {
|
||||
const query = projectId ? `?projectId=${projectId}` : ''
|
||||
|
|
@ -130,4 +139,11 @@ export const reportService = {
|
|||
async listHistory(orgId: number): Promise<GeneratedReportOutput[]> {
|
||||
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),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue