feat(ai): adicionar criacao de tarefas por linguagem natural e enriquecimento com Ollama/Qwen
This commit is contained in:
parent
8adcd6c417
commit
f1782171ff
19 changed files with 860 additions and 27 deletions
|
|
@ -36,30 +36,30 @@ public class OllamaService {
|
|||
.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.";
|
||||
public String chat(String systemPrompt, String userPrompt, boolean jsonFormat) {
|
||||
if (userPrompt == null || userPrompt.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
if (jsonFormat) {
|
||||
payload.put("format", "json");
|
||||
}
|
||||
|
||||
List<Map<String, String>> messages = List.of(
|
||||
Map.of("role", "system", "content", effectiveSystemPrompt),
|
||||
Map.of("role", "user", "content", tasksContext)
|
||||
);
|
||||
List<Map<String, String>> messages = new java.util.ArrayList<>();
|
||||
if (systemPrompt != null && !systemPrompt.isBlank()) {
|
||||
messages.add(Map.of("role", "system", "content", systemPrompt));
|
||||
}
|
||||
messages.add(Map.of("role", "user", "content", userPrompt));
|
||||
payload.put("messages", messages);
|
||||
|
||||
Map<String, Object> options = Map.of(
|
||||
"num_ctx", 8192,
|
||||
"temperature", 0.3
|
||||
"num_ctx", 4096,
|
||||
"temperature", 0.2
|
||||
);
|
||||
payload.put("options", options);
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ public class OllamaService {
|
|||
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
|
||||
.build();
|
||||
|
||||
LOGGER.infof("Enviando requisição de resumo para Ollama (%s) no modelo %s...", url, model);
|
||||
LOGGER.infof("Enviando requisição para Ollama (%s) no modelo %s...", url, model);
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
|
|
@ -86,15 +86,28 @@ public class OllamaService {
|
|||
sanitized = sanitized.substring(sanitized.indexOf("\n\n") + 2).trim();
|
||||
}
|
||||
|
||||
LOGGER.infof("Resumo com IA gerado com sucesso pelo Ollama (%d caracteres)", sanitized.length());
|
||||
LOGGER.infof("Resposta da IA gerada 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());
|
||||
LOGGER.warnf("Erro ao conectar ou processar requisição no Ollama: %s", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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.";
|
||||
|
||||
String result = chat(effectiveSystemPrompt, tasksContext, false);
|
||||
return result != null ? result : "Não foi possível gerar o resumo com IA neste momento.";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import net.beetechgroup.beetask.usecase.task.start.StartTaskUseCase;
|
|||
import net.beetechgroup.beetask.usecase.task.stop.StopTaskUseCase;
|
||||
import net.beetechgroup.beetask.usecase.task.update.UpdateTaskStatusUseCase;
|
||||
import net.beetechgroup.beetask.usecase.task.update.UpdateTaskUseCase;
|
||||
import net.beetechgroup.beetask.usecase.task.ai.EnrichTaskDescriptionUseCase;
|
||||
import net.beetechgroup.beetask.usecase.task.ai.ParseTaskWithAiUseCase;
|
||||
import net.beetechgroup.beetask.frameworks.ai.OllamaService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import net.beetechgroup.beetask.usecase.task.get.GetTaskUseCase;
|
||||
|
||||
@ApplicationScoped
|
||||
|
|
@ -73,5 +77,25 @@ public class TaskUseCaseConfig {
|
|||
UserOrganizationRepository userOrganizationRepository) {
|
||||
return new net.beetechgroup.beetask.usecase.task.delete.DeleteTaskUseCase(taskRepository, userRepository, userOrganizationRepository);
|
||||
}
|
||||
|
||||
@Produces
|
||||
public ParseTaskWithAiUseCase parseTaskWithAiUseCase(
|
||||
OllamaService ollamaService,
|
||||
ProjectRepository projectRepository,
|
||||
CategoryRepository categoryRepository,
|
||||
UserOrganizationRepository userOrganizationRepository,
|
||||
AuthorizeOrganizationMemberUseCase authorizer,
|
||||
ObjectMapper objectMapper) {
|
||||
return new ParseTaskWithAiUseCase(ollamaService, projectRepository, categoryRepository, userOrganizationRepository, authorizer, objectMapper);
|
||||
}
|
||||
|
||||
@Produces
|
||||
public EnrichTaskDescriptionUseCase enrichTaskDescriptionUseCase(
|
||||
OllamaService ollamaService,
|
||||
ProjectRepository projectRepository,
|
||||
CategoryRepository categoryRepository,
|
||||
AuthorizeOrganizationMemberUseCase authorizer) {
|
||||
return new EnrichTaskDescriptionUseCase(ollamaService, projectRepository, categoryRepository, authorizer);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||
|
||||
public record EnrichTaskDescriptionRequest(
|
||||
Long organizationId,
|
||||
String title,
|
||||
String currentDescription,
|
||||
Long projectId,
|
||||
Long categoryId
|
||||
) {}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||
|
||||
public record EnrichTaskDescriptionResponse(
|
||||
String description
|
||||
) {}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||
|
||||
public record ParseTaskWithAiRequest(
|
||||
Long organizationId,
|
||||
String prompt
|
||||
) {}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||
|
||||
public record ParsedTaskResponse(
|
||||
String title,
|
||||
String description,
|
||||
Long projectId,
|
||||
Long categoryId,
|
||||
Long assigneeUserId,
|
||||
String status
|
||||
) {}
|
||||
|
|
@ -35,6 +35,12 @@ import net.beetechgroup.beetask.usecase.task.start.StartTaskUseCase;
|
|||
import net.beetechgroup.beetask.usecase.task.stop.StopTaskUseCase;
|
||||
import net.beetechgroup.beetask.usecase.task.update.UpdateTaskStatusUseCase;
|
||||
import net.beetechgroup.beetask.usecase.task.update.UpdateTaskUseCase;
|
||||
import net.beetechgroup.beetask.usecase.task.ai.EnrichTaskDescriptionInput;
|
||||
import net.beetechgroup.beetask.usecase.task.ai.EnrichTaskDescriptionOutput;
|
||||
import net.beetechgroup.beetask.usecase.task.ai.EnrichTaskDescriptionUseCase;
|
||||
import net.beetechgroup.beetask.usecase.task.ai.ParseTaskWithAiInput;
|
||||
import net.beetechgroup.beetask.usecase.task.ai.ParsedTaskOutput;
|
||||
import net.beetechgroup.beetask.usecase.task.ai.ParseTaskWithAiUseCase;
|
||||
import net.beetechgroup.beetask.usecase.task.get.GetTaskUseCase;
|
||||
|
||||
@Path("/tasks")
|
||||
|
|
@ -72,6 +78,12 @@ public class TaskController {
|
|||
@Inject
|
||||
DeleteTaskUseCase deleteTaskUseCase;
|
||||
|
||||
@Inject
|
||||
ParseTaskWithAiUseCase parseTaskWithAiUseCase;
|
||||
|
||||
@Inject
|
||||
EnrichTaskDescriptionUseCase enrichTaskDescriptionUseCase;
|
||||
|
||||
@Inject
|
||||
SecurityIdentity securityIdentity;
|
||||
|
||||
|
|
@ -179,4 +191,44 @@ public class TaskController {
|
|||
return tasks;
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/ai/parse")
|
||||
@Operation(summary = "Parse natural language task", description = "Extracts task details from natural language using AI")
|
||||
@APIResponse(responseCode = "200", description = "Task parsed successfully")
|
||||
public ParsedTaskResponse parseTaskWithAi(ParseTaskWithAiRequest request) {
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
LOGGER.infof("User %s requested AI task parsing for org %d", email, request.organizationId());
|
||||
ParsedTaskOutput output = parseTaskWithAiUseCase.execute(
|
||||
new ParseTaskWithAiInput(email, request.organizationId(), request.prompt())
|
||||
);
|
||||
return new ParsedTaskResponse(
|
||||
output.title(),
|
||||
output.description(),
|
||||
output.projectId(),
|
||||
output.categoryId(),
|
||||
output.assigneeUserId(),
|
||||
output.status()
|
||||
);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/ai/enrich")
|
||||
@Operation(summary = "Enrich task description", description = "Generates structured description and acceptance criteria using AI")
|
||||
@APIResponse(responseCode = "200", description = "Task description enriched successfully")
|
||||
public EnrichTaskDescriptionResponse enrichTaskDescription(EnrichTaskDescriptionRequest request) {
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
LOGGER.infof("User %s requested AI description enrichment for task '%s'", email, request.title());
|
||||
EnrichTaskDescriptionOutput output = enrichTaskDescriptionUseCase.execute(
|
||||
new EnrichTaskDescriptionInput(
|
||||
email,
|
||||
request.organizationId(),
|
||||
request.title(),
|
||||
request.currentDescription(),
|
||||
request.projectId(),
|
||||
request.categoryId()
|
||||
)
|
||||
);
|
||||
return new EnrichTaskDescriptionResponse(output.description());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package net.beetechgroup.beetask.usecase.task.ai;
|
||||
|
||||
public record EnrichTaskDescriptionInput(
|
||||
String userEmail,
|
||||
Long organizationId,
|
||||
String title,
|
||||
String currentDescription,
|
||||
Long projectId,
|
||||
Long categoryId
|
||||
) {}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package net.beetechgroup.beetask.usecase.task.ai;
|
||||
|
||||
public record EnrichTaskDescriptionOutput(
|
||||
String description
|
||||
) {}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package net.beetechgroup.beetask.usecase.task.ai;
|
||||
|
||||
import net.beetechgroup.beetask.entities.Category;
|
||||
import net.beetechgroup.beetask.entities.Project;
|
||||
import net.beetechgroup.beetask.frameworks.ai.OllamaService;
|
||||
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
public class EnrichTaskDescriptionUseCase {
|
||||
private static final Logger LOGGER = Logger.getLogger(EnrichTaskDescriptionUseCase.class);
|
||||
|
||||
private final OllamaService ollamaService;
|
||||
private final ProjectRepository projectRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||
|
||||
public EnrichTaskDescriptionUseCase(
|
||||
OllamaService ollamaService,
|
||||
ProjectRepository projectRepository,
|
||||
CategoryRepository categoryRepository,
|
||||
AuthorizeOrganizationMemberUseCase authorizer
|
||||
) {
|
||||
this.ollamaService = ollamaService;
|
||||
this.projectRepository = projectRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.authorizer = authorizer;
|
||||
}
|
||||
|
||||
public EnrichTaskDescriptionOutput execute(EnrichTaskDescriptionInput input) {
|
||||
if (input.title() == null || input.title().isBlank()) {
|
||||
throw new IllegalArgumentException("O título da tarefa é obrigatório para gerar ou enriquecer a descrição.");
|
||||
}
|
||||
|
||||
authorizer.execute(input.userEmail(), input.organizationId());
|
||||
|
||||
String projectName = null;
|
||||
if (input.projectId() != null) {
|
||||
projectName = projectRepository.findProjectById(input.projectId())
|
||||
.map(Project::getName)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
String categoryName = null;
|
||||
if (input.categoryId() != null) {
|
||||
categoryName = categoryRepository.findCategoryById(input.categoryId())
|
||||
.map(Category::getName)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
StringBuilder systemPrompt = new StringBuilder();
|
||||
systemPrompt.append("Você é um especialista em engenharia de software e gestão ágil de projetos.\n");
|
||||
systemPrompt.append("Sua missão é gerar uma descrição detalhada, clara e profissional em formato Markdown para uma tarefa de trabalho.\n\n");
|
||||
systemPrompt.append("Estruture a descrição OBRIGATORIAMENTE no seguinte padrão:\n");
|
||||
systemPrompt.append("### Contexto e Objetivo\n");
|
||||
systemPrompt.append("(Explique de forma objetiva o que motivou a tarefa e a meta principal)\n\n");
|
||||
systemPrompt.append("### Escopo de Execução\n");
|
||||
systemPrompt.append("(Detalhe os passos e o que precisa ser desenvolvido ou executado)\n\n");
|
||||
systemPrompt.append("### Critérios de Aceitação\n");
|
||||
systemPrompt.append("- [ ] Critério 1 verificável\n");
|
||||
systemPrompt.append("- [ ] Critério 2 verificável\n");
|
||||
systemPrompt.append("- [ ] Critério 3 verificável\n\n");
|
||||
systemPrompt.append("IMPORTANTE:\n");
|
||||
systemPrompt.append("- Responda APENAS com o texto Markdown da descrição. Não inclua saudações, introduções ('Aqui está...') ou blocos de código delimitadores ```markdown no início ou fim.\n");
|
||||
systemPrompt.append("- Use tom profissional e direto em português brasileiro.");
|
||||
|
||||
StringBuilder userPrompt = new StringBuilder();
|
||||
userPrompt.append(String.format("Título da Tarefa: %s\n", input.title().trim()));
|
||||
if (projectName != null) {
|
||||
userPrompt.append(String.format("Projeto: %s\n", projectName));
|
||||
}
|
||||
if (categoryName != null) {
|
||||
userPrompt.append(String.format("Categoria: %s\n", categoryName));
|
||||
}
|
||||
if (input.currentDescription() != null && !input.currentDescription().isBlank()) {
|
||||
userPrompt.append(String.format("\nAnotações / Rascunho inicial do usuário:\n%s\n", input.currentDescription().trim()));
|
||||
}
|
||||
|
||||
String enriched = ollamaService != null
|
||||
? ollamaService.chat(systemPrompt.toString(), userPrompt.toString(), false)
|
||||
: null;
|
||||
|
||||
if (enriched == null || enriched.isBlank()) {
|
||||
String fallback = input.currentDescription() != null && !input.currentDescription().isBlank()
|
||||
? input.currentDescription()
|
||||
: "Não foi possível enriquecer a descrição com a IA no momento.";
|
||||
return new EnrichTaskDescriptionOutput(fallback);
|
||||
}
|
||||
|
||||
// Limpeza defensiva caso o modelo coloque ```markdown no início ou fim
|
||||
String cleaned = enriched.replaceAll("^```markdown\\s*", "")
|
||||
.replaceAll("^```\\s*", "")
|
||||
.replaceAll("\\s*```$", "")
|
||||
.trim();
|
||||
|
||||
return new EnrichTaskDescriptionOutput(cleaned);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package net.beetechgroup.beetask.usecase.task.ai;
|
||||
|
||||
public record ParseTaskWithAiInput(
|
||||
String userEmail,
|
||||
Long organizationId,
|
||||
String prompt
|
||||
) {}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
package net.beetechgroup.beetask.usecase.task.ai;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import net.beetechgroup.beetask.entities.Category;
|
||||
import net.beetechgroup.beetask.entities.Project;
|
||||
import net.beetechgroup.beetask.entities.organization.UserOrganization;
|
||||
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
|
||||
import net.beetechgroup.beetask.frameworks.ai.OllamaService;
|
||||
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class ParseTaskWithAiUseCase {
|
||||
private static final Logger LOGGER = Logger.getLogger(ParseTaskWithAiUseCase.class);
|
||||
|
||||
private final OllamaService ollamaService;
|
||||
private final ProjectRepository projectRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final UserOrganizationRepository userOrganizationRepository;
|
||||
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ParseTaskWithAiUseCase(
|
||||
OllamaService ollamaService,
|
||||
ProjectRepository projectRepository,
|
||||
CategoryRepository categoryRepository,
|
||||
UserOrganizationRepository userOrganizationRepository,
|
||||
AuthorizeOrganizationMemberUseCase authorizer,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.ollamaService = ollamaService;
|
||||
this.projectRepository = projectRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.userOrganizationRepository = userOrganizationRepository;
|
||||
this.authorizer = authorizer;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public ParsedTaskOutput execute(ParseTaskWithAiInput input) {
|
||||
if (input.prompt() == null || input.prompt().isBlank()) {
|
||||
throw new IllegalArgumentException("O prompt da tarefa não pode estar vazio.");
|
||||
}
|
||||
|
||||
authorizer.execute(input.userEmail(), input.organizationId());
|
||||
|
||||
List<Project> projects = input.organizationId() != null
|
||||
? projectRepository.findByOrganizationId(input.organizationId())
|
||||
: List.of();
|
||||
List<Category> categories = input.organizationId() != null
|
||||
? categoryRepository.findByOrganizationId(input.organizationId())
|
||||
: List.of();
|
||||
List<UserOrganization> members = input.organizationId() != null
|
||||
? userOrganizationRepository.findByOrganizationIdAndStatus(input.organizationId(), UserOrganizationStatus.ACTIVE)
|
||||
: List.of();
|
||||
|
||||
Set<Long> validProjectIds = projects.stream().map(Project::getId).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
Set<Long> validCategoryIds = categories.stream().map(Category::getId).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
Set<Long> validMemberUserIds = members.stream()
|
||||
.filter(m -> m.getUser() != null && m.getUser().getId() != null)
|
||||
.map(m -> m.getUser().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
StringBuilder promptBuilder = new StringBuilder();
|
||||
promptBuilder.append("Você é o assistente inteligente do BeeTask (sistema de gestão de tarefas corporativas).\n");
|
||||
promptBuilder.append("Sua função é interpretar a instrução em linguagem natural e extrair dados estruturados para preencher uma nova tarefa.\n\n");
|
||||
promptBuilder.append("Você DEVE responder EXCLUSIVAMENTE um objeto JSON válido no seguinte formato:\n");
|
||||
promptBuilder.append("{\n");
|
||||
promptBuilder.append(" \"title\": \"Título limpo, claro e conciso da tarefa (máximo 100 caracteres)\",\n");
|
||||
promptBuilder.append(" \"description\": \"Descrição detalhada com escopo e checklist de critérios de aceitação se aplicável\",\n");
|
||||
promptBuilder.append(" \"projectId\": <ID numérico do projeto correspondente ou null>,\n");
|
||||
promptBuilder.append(" \"categoryId\": <ID numérico da categoria correspondente ou null>,\n");
|
||||
promptBuilder.append(" \"assigneeUserId\": <ID numérico do membro responsável citado ou null>\n");
|
||||
promptBuilder.append("}\n\n");
|
||||
|
||||
promptBuilder.append("Contexto disponível na organização:\n");
|
||||
promptBuilder.append("Projetos:\n");
|
||||
if (projects.isEmpty()) {
|
||||
promptBuilder.append(" (Nenhum projeto cadastrado)\n");
|
||||
} else {
|
||||
for (Project p : projects) {
|
||||
promptBuilder.append(String.format(" - ID %d: %s\n", p.getId(), p.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
promptBuilder.append("Categorias:\n");
|
||||
if (categories.isEmpty()) {
|
||||
promptBuilder.append(" (Nenhuma categoria cadastrada)\n");
|
||||
} else {
|
||||
for (Category c : categories) {
|
||||
promptBuilder.append(String.format(" - ID %d: %s\n", c.getId(), c.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
promptBuilder.append("Membros disponíveis:\n");
|
||||
if (members.isEmpty()) {
|
||||
promptBuilder.append(" (Nenhum membro cadastrado)\n");
|
||||
} else {
|
||||
for (UserOrganization m : members) {
|
||||
if (m.getUser() != null) {
|
||||
promptBuilder.append(String.format(" - ID %d: %s (%s)\n",
|
||||
m.getUser().getId(),
|
||||
m.getUser().getName() != null ? m.getUser().getName() : "Sem nome",
|
||||
m.getUser().getEmail() != null ? m.getUser().getEmail() : ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
promptBuilder.append("\nRegras mandatórias:\n");
|
||||
promptBuilder.append("1. Apenas retorne IDs numéricos que estejam explicitamente listados acima. Caso não encontre correspondência direta, use null.\n");
|
||||
promptBuilder.append("2. Se a instrução do usuário contiver itens ou passos, formate a descrição com marcadores Markdown ou critérios de aceite.\n");
|
||||
promptBuilder.append("3. Não inclua texto antes ou depois do JSON.");
|
||||
|
||||
String rawJson = ollamaService != null
|
||||
? ollamaService.chat(promptBuilder.toString(), input.prompt(), true)
|
||||
: null;
|
||||
|
||||
if (rawJson != null && !rawJson.isBlank()) {
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(rawJson);
|
||||
String title = node.path("title").asText("").trim();
|
||||
if (title.isBlank()) {
|
||||
title = input.prompt().trim();
|
||||
}
|
||||
String description = node.path("description").asText("").trim();
|
||||
|
||||
Long projectId = node.hasNonNull("projectId") ? node.path("projectId").asLong() : null;
|
||||
if (projectId != null && !validProjectIds.contains(projectId)) {
|
||||
LOGGER.infof("AI sugeriu projectId %d inválido para a org, descartando...", projectId);
|
||||
projectId = null;
|
||||
}
|
||||
|
||||
Long categoryId = node.hasNonNull("categoryId") ? node.path("categoryId").asLong() : null;
|
||||
if (categoryId != null && !validCategoryIds.contains(categoryId)) {
|
||||
LOGGER.infof("AI sugeriu categoryId %d inválido para a org, descartando...", categoryId);
|
||||
categoryId = null;
|
||||
}
|
||||
|
||||
Long assigneeUserId = node.hasNonNull("assigneeUserId") ? node.path("assigneeUserId").asLong() : null;
|
||||
if (assigneeUserId != null && !validMemberUserIds.contains(assigneeUserId)) {
|
||||
LOGGER.infof("AI sugeriu assigneeUserId %d inválido para a org, descartando...", assigneeUserId);
|
||||
assigneeUserId = null;
|
||||
}
|
||||
|
||||
return new ParsedTaskOutput(
|
||||
title,
|
||||
description,
|
||||
projectId,
|
||||
categoryId,
|
||||
assigneeUserId,
|
||||
"NOT_STARTED"
|
||||
);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warnf("Falha ao analisar JSON retornado pelo Ollama (%s): %s", rawJson, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback defensivo: se a IA estiver offline ou não retornar JSON válido
|
||||
return new ParsedTaskOutput(
|
||||
input.prompt().trim(),
|
||||
"",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"NOT_STARTED"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package net.beetechgroup.beetask.usecase.task.ai;
|
||||
|
||||
public record ParsedTaskOutput(
|
||||
String title,
|
||||
String description,
|
||||
Long projectId,
|
||||
Long categoryId,
|
||||
Long assigneeUserId,
|
||||
String status
|
||||
) {}
|
||||
|
|
@ -15,12 +15,14 @@ import {
|
|||
FileText,
|
||||
AlertCircle,
|
||||
ExternalLink,
|
||||
Wand2,
|
||||
} from 'lucide-react'
|
||||
import type { Task, TaskStatus, Project, Category, TaskHistoryItem, TaskHistoryItemInput } from '../../types/task'
|
||||
import type { Task, TaskStatus, Project, Category, TaskHistoryItem, TaskHistoryItemInput, ParsedAiTask } from '../../types/task'
|
||||
import type { OrganizationMember } from '../../services/organizationService'
|
||||
import { projectService } from '../../services/projectService'
|
||||
import { categoryService } from '../../services/categoryService'
|
||||
import { organizationService } from '../../services/organizationService'
|
||||
import { taskService } from '../../services/taskService'
|
||||
import { CustomSelect } from '../ui/Select'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { canDeleteTask } from '../../lib/taskPermissions'
|
||||
|
|
@ -52,6 +54,7 @@ interface CreateTaskModalProps {
|
|||
initialStatus?: TaskStatus
|
||||
initialTask?: Task | null
|
||||
initialTab?: 'details' | 'history'
|
||||
initialDraft?: Partial<ParsedAiTask> | null
|
||||
}
|
||||
|
||||
export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
|
|
@ -63,6 +66,7 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
initialStatus = 'NOT_STARTED',
|
||||
initialTask = null,
|
||||
initialTab = 'details',
|
||||
initialDraft = null,
|
||||
}) => {
|
||||
const { user, activeOrg } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState<'details' | 'history'>('details')
|
||||
|
|
@ -103,6 +107,57 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [showAddCategory, setShowAddCategory] = useState(false)
|
||||
|
||||
// AI Helper states
|
||||
const [aiQuickPrompt, setAiQuickPrompt] = useState('')
|
||||
const [isParsingInline, setIsParsingInline] = useState(false)
|
||||
const [isEnriching, setIsEnriching] = useState(false)
|
||||
const [enrichMessage, setEnrichMessage] = useState<string | null>(null)
|
||||
|
||||
const handleInlineAiParse = async () => {
|
||||
if (!aiQuickPrompt.trim() || isParsingInline) return
|
||||
setIsParsingInline(true)
|
||||
setError(null)
|
||||
try {
|
||||
const parsed = await taskService.parseTaskWithAi(organizationId, aiQuickPrompt.trim())
|
||||
if (parsed.title) setTitle(parsed.title)
|
||||
if (parsed.description) setDescription(parsed.description)
|
||||
if (parsed.projectId !== undefined) setProjectId(parsed.projectId)
|
||||
if (parsed.categoryId !== undefined) setCategoryId(parsed.categoryId)
|
||||
if (parsed.assigneeUserId !== undefined) setAssigneeUserId(parsed.assigneeUserId)
|
||||
setAiQuickPrompt('')
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Falha ao processar com IA.'
|
||||
setError(msg)
|
||||
} finally {
|
||||
setIsParsingInline(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnrichDescription = async () => {
|
||||
if (!title.trim() || isEnriching) return
|
||||
setIsEnriching(true)
|
||||
setEnrichMessage(null)
|
||||
try {
|
||||
const res = await taskService.enrichTaskDescription({
|
||||
organizationId,
|
||||
title: title.trim(),
|
||||
currentDescription: description.trim(),
|
||||
projectId,
|
||||
categoryId,
|
||||
})
|
||||
if (res?.description) {
|
||||
setDescription(res.description)
|
||||
setEnrichMessage('Descrição estruturada com IA com sucesso!')
|
||||
setTimeout(() => setEnrichMessage(null), 3500)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Erro ao enriquecer descrição:', err)
|
||||
setError('Não foi possível gerar a descrição com IA no momento.')
|
||||
} finally {
|
||||
setIsEnriching(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Populate data when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
|
@ -117,6 +172,16 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
setExternalUrl(initialTask.externalUrl || '')
|
||||
setLocalHistory(initialTask.history ? [...initialTask.history] : [])
|
||||
setActiveTab(initialTab)
|
||||
} else if (initialDraft) {
|
||||
setTitle(initialDraft.title || '')
|
||||
setDescription(initialDraft.description || '')
|
||||
setStatus(initialDraft.status || initialStatus)
|
||||
setProjectId(initialDraft.projectId)
|
||||
setCategoryId(initialDraft.categoryId)
|
||||
setAssigneeUserId(initialDraft.assigneeUserId)
|
||||
setExternalUrl('')
|
||||
setLocalHistory([])
|
||||
setActiveTab('details')
|
||||
} else {
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
|
|
@ -134,6 +199,10 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
}
|
||||
|
||||
setError(null)
|
||||
setEnrichMessage(null)
|
||||
setAiQuickPrompt('')
|
||||
setIsParsingInline(false)
|
||||
setIsEnriching(false)
|
||||
setShowAddProject(false)
|
||||
setShowAddCategory(false)
|
||||
setIsHistoryFormOpen(false)
|
||||
|
|
@ -456,6 +525,53 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
{/* TAB 1: General Details */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="space-y-4">
|
||||
{/* Inline AI Quick Prompt (for creating new tasks) */}
|
||||
{!initialTask && (
|
||||
<div className="p-3 rounded-xl bg-purple-500/10 border border-purple-500/20 text-xs space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 font-semibold text-purple-300">
|
||||
<Sparkles className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Preencher com IA</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-purple-400/80">Linguagem Natural</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
disabled={isParsingInline}
|
||||
value={aiQuickPrompt}
|
||||
onChange={(e) => setAiQuickPrompt(e.target.value)}
|
||||
placeholder="Ex: Corrigir erro no login, categoria Bugfix, projeto BeeTask..."
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-purple-500/30 bg-surface text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleInlineAiParse()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isParsingInline || !aiQuickPrompt.trim()}
|
||||
onClick={handleInlineAiParse}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-purple-600 hover:bg-purple-500 text-white font-medium text-xs shadow-sm transition-all disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{isParsingInline ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>Processando...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 className="w-3 h-3" />
|
||||
<span>Preencher</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1.5">
|
||||
|
|
@ -473,11 +589,38 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1.5">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="block text-xs font-semibold text-foreground">
|
||||
Descrição detalhada
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEnrichDescription}
|
||||
disabled={isEnriching || !title.trim()}
|
||||
title={!title.trim() ? "Preencha o título da tarefa primeiro" : "Gerar descrição estruturada e critérios de aceitação com IA"}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-purple-400 hover:text-purple-300 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{isEnriching ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>Estruturando com IA...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-3 h-3 text-purple-400" />
|
||||
<span>Melhorar com IA ✨</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{enrichMessage && (
|
||||
<div className="mb-2 text-[11px] text-emerald-400 flex items-center gap-1">
|
||||
<Check className="w-3 h-3" />
|
||||
<span>{enrichMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
rows={3}
|
||||
rows={4}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Descreva o escopo, critérios de aceite ou observações importantes..."
|
||||
|
|
|
|||
172
frontend/src/components/tasks/CreateWithAiModal.tsx
Normal file
172
frontend/src/components/tasks/CreateWithAiModal.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import React, { useState } from 'react'
|
||||
import { Sparkles, Loader2, X, AlertCircle, Wand2, Lightbulb } from 'lucide-react'
|
||||
import type { ParsedAiTask } from '../../types/task'
|
||||
import { taskService } from '../../services/taskService'
|
||||
|
||||
interface CreateWithAiModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
organizationId: number
|
||||
onSuccess: (parsedData: ParsedAiTask) => void
|
||||
}
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
'Corrigir bug na exportação de relatórios para PDF no projeto BeeTask categoria Bugfix para Gabriel',
|
||||
'Desenvolver tela de configurações de notificações no projeto Frontend, categoria Feature',
|
||||
'Reunião com cliente amanhã às 15h para alinhamento de escopo e prazos, categoria Reunião',
|
||||
'Configurar pipeline de CI/CD para deploy automático do backend, categoria DevOps',
|
||||
]
|
||||
|
||||
export const CreateWithAiModal: React.FC<CreateWithAiModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
organizationId,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!prompt.trim() || loading) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const parsed = await taskService.parseTaskWithAi(organizationId, prompt.trim())
|
||||
onSuccess(parsed)
|
||||
setPrompt('')
|
||||
onClose()
|
||||
} catch (err: unknown) {
|
||||
console.error('Falha ao processar tarefa com IA:', err)
|
||||
const msg = err instanceof Error ? err.message : 'Não foi possível interpretar a tarefa com IA. Verifique se o serviço está ativo.'
|
||||
setError(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectExample = (text: string) => {
|
||||
setPrompt(text)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 overflow-y-auto animate-fade-in">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/75 backdrop-blur-xs transition-opacity"
|
||||
onClick={loading ? undefined : onClose}
|
||||
/>
|
||||
|
||||
{/* Modal Dialog */}
|
||||
<div className="relative w-full max-w-xl rounded-2xl border border-purple-500/30 bg-surface-raised shadow-2xl shadow-purple-500/10 z-10 overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 sm:p-5 border-b border-border bg-gradient-to-r from-purple-500/10 via-surface to-surface">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-purple-500/20 border border-purple-500/30 flex items-center justify-center text-purple-400 shadow-inner shadow-purple-500/20">
|
||||
<Sparkles className="w-5 h-5 animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-bold text-foreground">Criar Tarefa com IA</h2>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full bg-purple-500/20 text-purple-300 border border-purple-500/30">
|
||||
Qwen Local
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
Descreva a demanda em linguagem natural e deixe a IA preencher todos os campos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-foreground hover:bg-surface transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<form onSubmit={handleSubmit} className="p-4 sm:p-5 space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-rose-500/10 border border-rose-500/25 text-rose-300 text-xs">
|
||||
<AlertCircle className="w-4 h-4 text-rose-400 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1.5">
|
||||
O que precisa ser feito? <span className="text-purple-400">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
rows={4}
|
||||
value={prompt}
|
||||
disabled={loading}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Ex: Criar endpoint para autenticação com Google no backend Quarkus, vincular ao projeto BeeTask, categoria Backend e atribuir ao Gabriel..."
|
||||
className="w-full px-3.5 py-3 rounded-xl border border-border bg-surface text-sm text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-purple-500/40 focus:border-purple-500 transition-all resize-none leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prompt suggestions */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted">
|
||||
<Lightbulb className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Exemplos rápidos:</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{EXAMPLE_PROMPTS.map((ex, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => handleSelectExample(ex)}
|
||||
className="text-left text-[11px] px-2.5 py-1 rounded-lg bg-surface border border-border-soft hover:border-purple-500/40 text-muted hover:text-foreground transition-all hover:bg-purple-500/5 line-clamp-1"
|
||||
>
|
||||
{ex}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl text-xs font-medium text-muted hover:text-foreground hover:bg-surface transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !prompt.trim()}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-600 text-white font-semibold text-xs hover:from-purple-500 hover:to-indigo-500 shadow-md shadow-purple-500/25 transition-all hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Interpretando com IA...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
<span>Gerar Tarefa ✨</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -30,9 +30,10 @@ import { useTimer } from '../contexts/TimerContext'
|
|||
import { taskService } from '../services/taskService'
|
||||
import { projectService } from '../services/projectService'
|
||||
import { reportService, type ReportTemplateOutput } from '../services/reportService'
|
||||
import type { Task, Project } from '../types/task'
|
||||
import type { Task, Project, ParsedAiTask } from '../types/task'
|
||||
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
|
||||
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
|
||||
import { CreateWithAiModal } from '../components/tasks/CreateWithAiModal'
|
||||
import { GenerateReportModal } from '../components/reports/GenerateReportModal'
|
||||
import { PeriodSelector } from '../components/dashboard/PeriodSelector'
|
||||
import { CategoryTimePieChart } from '../components/dashboard/CategoryTimePieChart'
|
||||
|
|
@ -67,6 +68,8 @@ export const DashboardPage: React.FC = () => {
|
|||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [reportTemplates, setReportTemplates] = useState<ReportTemplateOutput[]>([])
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false)
|
||||
const [isAiModalOpen, setIsAiModalOpen] = useState(false)
|
||||
const [aiDraftTask, setAiDraftTask] = useState<ParsedAiTask | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -430,7 +433,20 @@ export const DashboardPage: React.FC = () => {
|
|||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
onClick={() => setIsAiModalOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3.5 py-2.5 rounded-xl bg-purple-600/15 border border-purple-500/30 text-purple-300 font-bold text-xs hover:bg-purple-600/25 hover:text-purple-200 shadow-sm transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] cursor-pointer shrink-0"
|
||||
title="Criar tarefa em linguagem natural com inteligência artificial"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-purple-400" />
|
||||
<span>Criar com IA</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAiDraftTask(null)
|
||||
setIsModalOpen(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand hover:bg-brand-strong text-white font-bold text-xs shadow-lg shadow-brand/25 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shrink-0"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
|
|
@ -1119,10 +1135,24 @@ export const DashboardPage: React.FC = () => {
|
|||
<>
|
||||
<CreateTaskModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false)
|
||||
setAiDraftTask(null)
|
||||
}}
|
||||
onSubmit={handleModalSubmit}
|
||||
organizationId={activeOrgId}
|
||||
initialStatus="NOT_STARTED"
|
||||
initialDraft={aiDraftTask}
|
||||
/>
|
||||
|
||||
<CreateWithAiModal
|
||||
isOpen={isAiModalOpen}
|
||||
onClose={() => setIsAiModalOpen(false)}
|
||||
organizationId={activeOrgId}
|
||||
onSuccess={(parsed) => {
|
||||
setAiDraftTask(parsed)
|
||||
setIsModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
<GenerateReportModal
|
||||
|
|
|
|||
|
|
@ -24,10 +24,11 @@ import { useAuth } from '../contexts/AuthContext'
|
|||
import { useTimer } from '../contexts/TimerContext'
|
||||
import { taskService } from '../services/taskService'
|
||||
import { projectService } from '../services/projectService'
|
||||
import type { Task, TaskStatus, Project } from '../types/task'
|
||||
import type { Task, TaskStatus, Project, ParsedAiTask } from '../types/task'
|
||||
import { TaskKanbanView, COLUMNS } from '../components/tasks/TaskKanbanView'
|
||||
import { TaskListView } from '../components/tasks/TaskListView'
|
||||
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
|
||||
import { CreateWithAiModal } from '../components/tasks/CreateWithAiModal'
|
||||
import { CustomSelect } from '../components/ui/Select'
|
||||
import {
|
||||
getLocalPreferences,
|
||||
|
|
@ -88,6 +89,8 @@ export const TasksPage: React.FC = () => {
|
|||
const [modalInitialStatus, setModalInitialStatus] = useState<TaskStatus>('NOT_STARTED')
|
||||
const [modalInitialTab, setModalInitialTab] = useState<'details' | 'history'>('details')
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||
const [isAiModalOpen, setIsAiModalOpen] = useState(false)
|
||||
const [aiDraftTask, setAiDraftTask] = useState<ParsedAiTask | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
// Load user preferences for hidden columns from backend
|
||||
|
|
@ -517,10 +520,24 @@ export const TasksPage: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Create with AI Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAiModalOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3.5 py-2.5 rounded-xl bg-purple-600/15 border border-purple-500/30 text-purple-300 font-semibold text-xs hover:bg-purple-600/25 hover:text-purple-200 shadow-sm transition-all hover:-translate-y-0.5 active:translate-y-0"
|
||||
title="Criar tarefa em linguagem natural com inteligência artificial"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-purple-400" />
|
||||
<span>Criar com IA</span>
|
||||
</button>
|
||||
|
||||
{/* New Task Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCreateModal('NOT_STARTED')}
|
||||
onClick={() => {
|
||||
setAiDraftTask(null)
|
||||
handleOpenCreateModal('NOT_STARTED')
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-brand text-white font-semibold text-xs hover:bg-brand-hover shadow-md shadow-brand/25 transition-all hover:-translate-y-0.5 active:translate-y-0"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
|
|
@ -818,6 +835,7 @@ export const TasksPage: React.FC = () => {
|
|||
onClose={() => {
|
||||
setIsModalOpen(false)
|
||||
setEditingTask(null)
|
||||
setAiDraftTask(null)
|
||||
}}
|
||||
onSubmit={handleModalSubmit}
|
||||
onDelete={handleDeleteTask}
|
||||
|
|
@ -825,6 +843,22 @@ export const TasksPage: React.FC = () => {
|
|||
initialStatus={modalInitialStatus}
|
||||
initialTask={editingTask}
|
||||
initialTab={modalInitialTab}
|
||||
initialDraft={aiDraftTask}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create with AI Modal */}
|
||||
{activeOrgId && (
|
||||
<CreateWithAiModal
|
||||
isOpen={isAiModalOpen}
|
||||
onClose={() => setIsAiModalOpen(false)}
|
||||
organizationId={activeOrgId}
|
||||
onSuccess={(parsed) => {
|
||||
setAiDraftTask(parsed)
|
||||
setEditingTask(null)
|
||||
setModalInitialStatus(parsed.status || 'NOT_STARTED')
|
||||
setIsModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { apiFetch } from '../lib/api'
|
||||
import type { Task, TaskStatus, CreateTaskPayload, ListTasksFilter } from '../types/task'
|
||||
import type { Task, TaskStatus, CreateTaskPayload, ListTasksFilter, ParsedAiTask } from '../types/task'
|
||||
|
||||
function buildQueryParams(filter?: ListTasksFilter): string {
|
||||
if (!filter) return ''
|
||||
|
|
@ -81,4 +81,24 @@ export const taskService = {
|
|||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
|
||||
async parseTaskWithAi(organizationId: number, prompt: string): Promise<ParsedAiTask> {
|
||||
return apiFetch<ParsedAiTask>('/tasks/ai/parse', {
|
||||
method: 'POST',
|
||||
body: { organizationId, prompt },
|
||||
})
|
||||
},
|
||||
|
||||
async enrichTaskDescription(payload: {
|
||||
organizationId: number
|
||||
title: string
|
||||
currentDescription?: string
|
||||
projectId?: number
|
||||
categoryId?: number
|
||||
}): Promise<{ description: string }> {
|
||||
return apiFetch<{ description: string }>('/tasks/ai/enrich', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,15 @@ export interface CreateTaskPayload {
|
|||
externalUrl?: string;
|
||||
}
|
||||
|
||||
export interface ParsedAiTask {
|
||||
title: string;
|
||||
description: string;
|
||||
projectId?: number;
|
||||
categoryId?: number;
|
||||
assigneeUserId?: number;
|
||||
status?: TaskStatus;
|
||||
}
|
||||
|
||||
export interface ListTasksFilter {
|
||||
organizationId?: number;
|
||||
text?: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue