feat(ai): adicionar criacao de tarefas por linguagem natural e enriquecimento com Ollama/Qwen

This commit is contained in:
Gabriel Menezes 2026-11-04 15:12:07 -04:00
parent 8adcd6c417
commit f1782171ff
19 changed files with 860 additions and 27 deletions

View file

@ -36,30 +36,30 @@ public class OllamaService {
.connectTimeout(Duration.ofSeconds(10)) .connectTimeout(Duration.ofSeconds(10))
.build(); .build();
public String generateSummary(String systemPrompt, String tasksContext) { public String chat(String systemPrompt, String userPrompt, boolean jsonFormat) {
if (tasksContext == null || tasksContext.isBlank()) { if (userPrompt == null || userPrompt.isBlank()) {
return "Nenhuma atividade registrada no período para resumo."; 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 { try {
Map<String, Object> payload = new HashMap<>(); Map<String, Object> payload = new HashMap<>();
payload.put("model", model); payload.put("model", model);
payload.put("stream", false); payload.put("stream", false);
payload.put("think", false); payload.put("think", false);
if (jsonFormat) {
payload.put("format", "json");
}
List<Map<String, String>> messages = List.of( List<Map<String, String>> messages = new java.util.ArrayList<>();
Map.of("role", "system", "content", effectiveSystemPrompt), if (systemPrompt != null && !systemPrompt.isBlank()) {
Map.of("role", "user", "content", tasksContext) messages.add(Map.of("role", "system", "content", systemPrompt));
); }
messages.add(Map.of("role", "user", "content", userPrompt));
payload.put("messages", messages); payload.put("messages", messages);
Map<String, Object> options = Map.of( Map<String, Object> options = Map.of(
"num_ctx", 8192, "num_ctx", 4096,
"temperature", 0.3 "temperature", 0.2
); );
payload.put("options", options); payload.put("options", options);
@ -73,7 +73,7 @@ public class OllamaService {
.POST(HttpRequest.BodyPublishers.ofString(requestBody)) .POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build(); .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()); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) { if (response.statusCode() == 200) {
@ -86,15 +86,28 @@ public class OllamaService {
sanitized = sanitized.substring(sanitized.indexOf("\n\n") + 2).trim(); 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; return sanitized;
} else { } else {
LOGGER.warnf("Ollama retornou status HTTP %d: %s", response.statusCode(), response.body()); LOGGER.warnf("Ollama retornou status HTTP %d: %s", response.statusCode(), response.body());
return null; return null;
} }
} catch (Exception e) { } 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; 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.";
}
} }

View file

@ -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.stop.StopTaskUseCase;
import net.beetechgroup.beetask.usecase.task.update.UpdateTaskStatusUseCase; import net.beetechgroup.beetask.usecase.task.update.UpdateTaskStatusUseCase;
import net.beetechgroup.beetask.usecase.task.update.UpdateTaskUseCase; 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; import net.beetechgroup.beetask.usecase.task.get.GetTaskUseCase;
@ApplicationScoped @ApplicationScoped
@ -73,5 +77,25 @@ public class TaskUseCaseConfig {
UserOrganizationRepository userOrganizationRepository) { UserOrganizationRepository userOrganizationRepository) {
return new net.beetechgroup.beetask.usecase.task.delete.DeleteTaskUseCase(taskRepository, userRepository, 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);
}
} }

View file

@ -0,0 +1,9 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
public record EnrichTaskDescriptionRequest(
Long organizationId,
String title,
String currentDescription,
Long projectId,
Long categoryId
) {}

View file

@ -0,0 +1,5 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
public record EnrichTaskDescriptionResponse(
String description
) {}

View file

@ -0,0 +1,6 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
public record ParseTaskWithAiRequest(
Long organizationId,
String prompt
) {}

View file

@ -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
) {}

View file

@ -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.stop.StopTaskUseCase;
import net.beetechgroup.beetask.usecase.task.update.UpdateTaskStatusUseCase; import net.beetechgroup.beetask.usecase.task.update.UpdateTaskStatusUseCase;
import net.beetechgroup.beetask.usecase.task.update.UpdateTaskUseCase; 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; import net.beetechgroup.beetask.usecase.task.get.GetTaskUseCase;
@Path("/tasks") @Path("/tasks")
@ -72,6 +78,12 @@ public class TaskController {
@Inject @Inject
DeleteTaskUseCase deleteTaskUseCase; DeleteTaskUseCase deleteTaskUseCase;
@Inject
ParseTaskWithAiUseCase parseTaskWithAiUseCase;
@Inject
EnrichTaskDescriptionUseCase enrichTaskDescriptionUseCase;
@Inject @Inject
SecurityIdentity securityIdentity; SecurityIdentity securityIdentity;
@ -179,4 +191,44 @@ public class TaskController {
return tasks; 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());
}
} }

View file

@ -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
) {}

View file

@ -0,0 +1,5 @@
package net.beetechgroup.beetask.usecase.task.ai;
public record EnrichTaskDescriptionOutput(
String description
) {}

View file

@ -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);
}
}

View file

@ -0,0 +1,7 @@
package net.beetechgroup.beetask.usecase.task.ai;
public record ParseTaskWithAiInput(
String userEmail,
Long organizationId,
String prompt
) {}

View file

@ -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"
);
}
}

View file

@ -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
) {}

View file

@ -15,12 +15,14 @@ import {
FileText, FileText,
AlertCircle, AlertCircle,
ExternalLink, ExternalLink,
Wand2,
} from 'lucide-react' } 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 type { OrganizationMember } from '../../services/organizationService'
import { projectService } from '../../services/projectService' import { projectService } from '../../services/projectService'
import { categoryService } from '../../services/categoryService' import { categoryService } from '../../services/categoryService'
import { organizationService } from '../../services/organizationService' import { organizationService } from '../../services/organizationService'
import { taskService } from '../../services/taskService'
import { CustomSelect } from '../ui/Select' import { CustomSelect } from '../ui/Select'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
import { canDeleteTask } from '../../lib/taskPermissions' import { canDeleteTask } from '../../lib/taskPermissions'
@ -52,6 +54,7 @@ interface CreateTaskModalProps {
initialStatus?: TaskStatus initialStatus?: TaskStatus
initialTask?: Task | null initialTask?: Task | null
initialTab?: 'details' | 'history' initialTab?: 'details' | 'history'
initialDraft?: Partial<ParsedAiTask> | null
} }
export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
@ -63,6 +66,7 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
initialStatus = 'NOT_STARTED', initialStatus = 'NOT_STARTED',
initialTask = null, initialTask = null,
initialTab = 'details', initialTab = 'details',
initialDraft = null,
}) => { }) => {
const { user, activeOrg } = useAuth() const { user, activeOrg } = useAuth()
const [activeTab, setActiveTab] = useState<'details' | 'history'>('details') const [activeTab, setActiveTab] = useState<'details' | 'history'>('details')
@ -103,6 +107,57 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
const [newCategoryName, setNewCategoryName] = useState('') const [newCategoryName, setNewCategoryName] = useState('')
const [showAddCategory, setShowAddCategory] = useState(false) 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 // Populate data when modal opens
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
@ -117,6 +172,16 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
setExternalUrl(initialTask.externalUrl || '') setExternalUrl(initialTask.externalUrl || '')
setLocalHistory(initialTask.history ? [...initialTask.history] : []) setLocalHistory(initialTask.history ? [...initialTask.history] : [])
setActiveTab(initialTab) 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 { } else {
setTitle('') setTitle('')
setDescription('') setDescription('')
@ -134,6 +199,10 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
} }
setError(null) setError(null)
setEnrichMessage(null)
setAiQuickPrompt('')
setIsParsingInline(false)
setIsEnriching(false)
setShowAddProject(false) setShowAddProject(false)
setShowAddCategory(false) setShowAddCategory(false)
setIsHistoryFormOpen(false) setIsHistoryFormOpen(false)
@ -456,6 +525,53 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
{/* TAB 1: General Details */} {/* TAB 1: General Details */}
{activeTab === 'details' && ( {activeTab === 'details' && (
<div className="space-y-4"> <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 */} {/* Title */}
<div> <div>
<label className="block text-xs font-semibold text-foreground mb-1.5"> <label className="block text-xs font-semibold text-foreground mb-1.5">
@ -473,11 +589,38 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
{/* Description */} {/* Description */}
<div> <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 Descrição detalhada
</label> </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 <textarea
rows={3} rows={4}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Descreva o escopo, critérios de aceite ou observações importantes..." placeholder="Descreva o escopo, critérios de aceite ou observações importantes..."

View 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>
)
}

View file

@ -30,9 +30,10 @@ import { useTimer } from '../contexts/TimerContext'
import { taskService } from '../services/taskService' import { taskService } from '../services/taskService'
import { projectService } from '../services/projectService' import { projectService } from '../services/projectService'
import { reportService, type ReportTemplateOutput } from '../services/reportService' 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 { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
import { CreateTaskModal } from '../components/tasks/CreateTaskModal' import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
import { CreateWithAiModal } from '../components/tasks/CreateWithAiModal'
import { GenerateReportModal } from '../components/reports/GenerateReportModal' import { GenerateReportModal } from '../components/reports/GenerateReportModal'
import { PeriodSelector } from '../components/dashboard/PeriodSelector' import { PeriodSelector } from '../components/dashboard/PeriodSelector'
import { CategoryTimePieChart } from '../components/dashboard/CategoryTimePieChart' import { CategoryTimePieChart } from '../components/dashboard/CategoryTimePieChart'
@ -67,6 +68,8 @@ export const DashboardPage: React.FC = () => {
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
const [reportTemplates, setReportTemplates] = useState<ReportTemplateOutput[]>([]) const [reportTemplates, setReportTemplates] = useState<ReportTemplateOutput[]>([])
const [isReportModalOpen, setIsReportModalOpen] = useState(false) const [isReportModalOpen, setIsReportModalOpen] = useState(false)
const [isAiModalOpen, setIsAiModalOpen] = useState(false)
const [aiDraftTask, setAiDraftTask] = useState<ParsedAiTask | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -430,7 +433,20 @@ export const DashboardPage: React.FC = () => {
<button <button
type="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" 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" /> <Plus className="w-4 h-4" />
@ -1119,10 +1135,24 @@ export const DashboardPage: React.FC = () => {
<> <>
<CreateTaskModal <CreateTaskModal
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)} onClose={() => {
setIsModalOpen(false)
setAiDraftTask(null)
}}
onSubmit={handleModalSubmit} onSubmit={handleModalSubmit}
organizationId={activeOrgId} organizationId={activeOrgId}
initialStatus="NOT_STARTED" initialStatus="NOT_STARTED"
initialDraft={aiDraftTask}
/>
<CreateWithAiModal
isOpen={isAiModalOpen}
onClose={() => setIsAiModalOpen(false)}
organizationId={activeOrgId}
onSuccess={(parsed) => {
setAiDraftTask(parsed)
setIsModalOpen(true)
}}
/> />
<GenerateReportModal <GenerateReportModal

View file

@ -24,10 +24,11 @@ import { useAuth } from '../contexts/AuthContext'
import { useTimer } from '../contexts/TimerContext' import { useTimer } from '../contexts/TimerContext'
import { taskService } from '../services/taskService' import { taskService } from '../services/taskService'
import { projectService } from '../services/projectService' 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 { TaskKanbanView, COLUMNS } from '../components/tasks/TaskKanbanView'
import { TaskListView } from '../components/tasks/TaskListView' import { TaskListView } from '../components/tasks/TaskListView'
import { CreateTaskModal } from '../components/tasks/CreateTaskModal' import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
import { CreateWithAiModal } from '../components/tasks/CreateWithAiModal'
import { CustomSelect } from '../components/ui/Select' import { CustomSelect } from '../components/ui/Select'
import { import {
getLocalPreferences, getLocalPreferences,
@ -88,6 +89,8 @@ export const TasksPage: React.FC = () => {
const [modalInitialStatus, setModalInitialStatus] = useState<TaskStatus>('NOT_STARTED') const [modalInitialStatus, setModalInitialStatus] = useState<TaskStatus>('NOT_STARTED')
const [modalInitialTab, setModalInitialTab] = useState<'details' | 'history'>('details') const [modalInitialTab, setModalInitialTab] = useState<'details' | 'history'>('details')
const [editingTask, setEditingTask] = useState<Task | null>(null) 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) const [actionError, setActionError] = useState<string | null>(null)
// Load user preferences for hidden columns from backend // Load user preferences for hidden columns from backend
@ -517,10 +520,24 @@ export const TasksPage: React.FC = () => {
</div> </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 */} {/* New Task Button */}
<button <button
type="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" 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" /> <Plus className="w-4 h-4" />
@ -818,6 +835,7 @@ export const TasksPage: React.FC = () => {
onClose={() => { onClose={() => {
setIsModalOpen(false) setIsModalOpen(false)
setEditingTask(null) setEditingTask(null)
setAiDraftTask(null)
}} }}
onSubmit={handleModalSubmit} onSubmit={handleModalSubmit}
onDelete={handleDeleteTask} onDelete={handleDeleteTask}
@ -825,6 +843,22 @@ export const TasksPage: React.FC = () => {
initialStatus={modalInitialStatus} initialStatus={modalInitialStatus}
initialTask={editingTask} initialTask={editingTask}
initialTab={modalInitialTab} 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> </div>

View file

@ -1,5 +1,5 @@
import { apiFetch } from '../lib/api' 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 { function buildQueryParams(filter?: ListTasksFilter): string {
if (!filter) return '' if (!filter) return ''
@ -81,4 +81,24 @@ export const taskService = {
method: 'DELETE', 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,
})
},
} }

View file

@ -60,6 +60,15 @@ export interface CreateTaskPayload {
externalUrl?: string; externalUrl?: string;
} }
export interface ParsedAiTask {
title: string;
description: string;
projectId?: number;
categoryId?: number;
assigneeUserId?: number;
status?: TaskStatus;
}
export interface ListTasksFilter { export interface ListTasksFilter {
organizationId?: number; organizationId?: number;
text?: string; text?: string;