feat: version 4.0.0 -- Asistente Nuevo proyecto con IA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -182,6 +182,8 @@ el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`.
|
|||||||
- `GET /api/ai/status` → `{ enabled }` (cualquier autenticado).
|
- `GET /api/ai/status` → `{ enabled }` (cualquier autenticado).
|
||||||
- `POST /api/ai/projects/:projectId/plan` `{ description?, lang? }` → `{ activities: [{ name, description, durationDays, tasks: [] }] }`. Requiere módulo Proyectos y escritura en el proyecto; 30/hora.
|
- `POST /api/ai/projects/:projectId/plan` `{ description?, lang? }` → `{ activities: [{ name, description, durationDays, tasks: [] }] }`. Requiere módulo Proyectos y escritura en el proyecto; 30/hora.
|
||||||
- `POST /api/ai/projects/:projectId/plan/apply` `{ activities }` → `{ created }` (crea actividades y tareas).
|
- `POST /api/ai/projects/:projectId/plan/apply` `{ activities }` → `{ created }` (crea actividades y tareas).
|
||||||
|
- `POST /api/ai/new-project/propose` `{ objective, startDate, lang? }` → `{ name, objective, description, projectTypeId, projectTypeName, estimatedEndDate, activities }`; `400` si el objetivo es corto o la fecha inválida (sin llamar a la IA), `422` si no es un proyecto. 30/hora.
|
||||||
|
- `POST /api/ai/new-project` `{ objective, startDate, name, description?, projectTypeId?, requestingDepartmentId, activities }` → `{ id, code }`. Roles que crean proyectos.
|
||||||
- `GET/PUT /api/settings/ai` `{ apiKey?, model, editVersion }` y `POST /api/settings/ai/test` — solo superadmin; la clave nunca se devuelve.
|
- `GET/PUT /api/settings/ai` `{ apiKey?, model, editVersion }` y `POST /api/settings/ai/test` — solo superadmin; la clave nunca se devuelve.
|
||||||
|
|
||||||
### Log de errores (`/api/error-logs`)
|
### Log de errores (`/api/error-logs`)
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# Changelog - project.linkedcorp.net
|
||||||
|
|
||||||
|
## [4.0.0] - 2026-10-23
|
||||||
|
### Added
|
||||||
|
- Asistente "Nuevo con IA" en Proyectos (roles que crean proyectos, con la IA configurada):
|
||||||
|
1) el usuario describe el objetivo (mín. 20 caracteres), elige departamento solicitante y fecha
|
||||||
|
de inicio — validado antes de llamar a la IA; 2) una sola llamada propone nombre, objetivo
|
||||||
|
redactado, descripción (alcance), tipo, fin estimado y actividades con tareas y días; 3) el
|
||||||
|
usuario revisa, edita el nombre, desmarca y crea. El proyecto queda Planificado, con quien lo
|
||||||
|
crea como responsable y único miembro; actividades encadenadas y tareas con fecha dentro de
|
||||||
|
su actividad, todas con responsable.
|
||||||
|
- Si el texto no describe un proyecto, la IA lo rechaza ("Solo puedo ayudarte a crear
|
||||||
|
proyectos") con un consumo mínimo. Ninguna actividad se crea sin tareas.
|
||||||
|
### Fixed
|
||||||
|
- "Plan con IA" → "Crear seleccionadas" fallaba (orden por columna inexistente en prioridades) y
|
||||||
|
el diálogo no mostraba bien las tareas; ahora muestran su duración. Al aplicar, las
|
||||||
|
actividades arrancan hoy o en el inicio del proyecto si aún no empezó.
|
||||||
|
|
||||||
## [3.2.0] - 2026-10-23
|
## [3.2.0] - 2026-10-23
|
||||||
### Added
|
### Added
|
||||||
- Integración IA (API de Anthropic): Configuración → "Inteligencia artificial (IA)", solo
|
- Integración IA (API de Anthropic): Configuración → "Inteligencia artificial (IA)", solo
|
||||||
|
|||||||
@@ -174,9 +174,8 @@ y recargar. Pendiente verificar el flujo completo en un despliegue autorizado; n
|
|||||||
- Reportes exportables (PDF/Excel/CSV) y vista ejecutiva separada.
|
- Reportes exportables (PDF/Excel/CSV) y vista ejecutiva separada.
|
||||||
- Permisos por accion dentro de cada modulo (hoy: acceso por modulo y usuario desde v3.1.0 +
|
- Permisos por accion dentro de cada modulo (hoy: acceso por modulo y usuario desde v3.1.0 +
|
||||||
roles fijos codificados).
|
roles fijos codificados).
|
||||||
- IA (v3.2.0 hecho: config + "Plan con IA" en Actividades). Siguiente: asistente "Nuevo proyecto con
|
- IA (v4.0.0 hecho: config, "Plan con IA" y asistente "Nuevo con IA"). Siguiente posible: resumen
|
||||||
IA" por pasos (objetivo -> datos fijos/elegidos -> una llamada -> revision), fechas del proyecto,
|
semanal del Panel; responsables por actividad entre los miembros del proyecto.
|
||||||
miembros/responsables y tareas con fechas. Despues: resumen semanal del Panel.
|
|
||||||
- Notificaciones por email/WhatsApp (hoy: solo in-app).
|
- Notificaciones por email/WhatsApp (hoy: solo in-app).
|
||||||
- Job programado para detectar actividades vencidas/por vencer (hoy: solo se notifica al asignar).
|
- Job programado para detectar actividades vencidas/por vencer (hoy: solo se notifica al asignar).
|
||||||
- DatePicker: sigue usando `<input type="date">` nativo (simplificacion deliberada del MVP).
|
- DatePicker: sigue usando `<input type="date">` nativo (simplificacion deliberada del MVP).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "3.2.0",
|
"version": "4.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -10,3 +10,11 @@ export const postProjectPlan = asyncHandler(async (req, res) => {
|
|||||||
export const postApplyProjectPlan = asyncHandler(async (req, res) => {
|
export const postApplyProjectPlan = asyncHandler(async (req, res) => {
|
||||||
ok(res, await service.applyProjectPlan(Number(req.params.projectId), req.body?.activities, req.currentUser), 201);
|
ok(res, await service.applyProjectPlan(Number(req.params.projectId), req.body?.activities, req.currentUser), 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const postProposeProject = asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await service.proposeNewProject(req.body || {}, req.currentUser));
|
||||||
|
});
|
||||||
|
|
||||||
|
export const postCreateProject = asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await service.createNewProject(req.body || {}, req.currentUser), 201);
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
|||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
import { requireModule } from '../../middleware/modules.js';
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
import { getStatus, postProjectPlan, postApplyProjectPlan } from './controller.js';
|
import { getStatus, postProjectPlan, postApplyProjectPlan, postProposeProject, postCreateProject } from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@ const aiLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 30, standardHeade
|
|||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
router.get('/status', getStatus);
|
router.get('/status', getStatus);
|
||||||
router.post('/projects/:projectId/plan', requireModule('projects'), aiLimiter, postProjectPlan);
|
router.post('/projects/:projectId/plan', requireModule('projects'), aiLimiter, postProjectPlan);
|
||||||
|
router.post('/new-project/propose', requireModule('projects'), aiLimiter, postProposeProject);
|
||||||
|
router.post('/new-project', requireModule('projects'), postCreateProject);
|
||||||
router.post('/projects/:projectId/plan/apply', requireModule('projects'), postApplyProjectPlan);
|
router.post('/projects/:projectId/plan/apply', requireModule('projects'), postApplyProjectPlan);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -3,17 +3,16 @@ import { HttpError } from '../../utils/http.js';
|
|||||||
import { askStructured } from '../../utils/ai.js';
|
import { askStructured } from '../../utils/ai.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
import { getAiConfig } from '../settings/service.js';
|
import { getAiConfig } from '../settings/service.js';
|
||||||
import { getProject, canWriteProject } from '../projects/service.js';
|
import { getProject, canWriteProject, createProject } from '../projects/service.js';
|
||||||
import { createActivity } from '../activities/service.js';
|
import { createActivity } from '../activities/service.js';
|
||||||
import { createTask } from '../tasks/service.js';
|
import { createTask } from '../tasks/service.js';
|
||||||
|
|
||||||
const MAX_ACTIVITIES = 15;
|
const MAX_ACTIVITIES = 15;
|
||||||
const MAX_TASKS = 10;
|
const MAX_TASKS = 10;
|
||||||
|
export const MIN_OBJECTIVE_LENGTH = 20;
|
||||||
|
const MAX_OBJECTIVE_LENGTH = 2000;
|
||||||
|
|
||||||
const PLAN_SCHEMA = {
|
const ACTIVITIES_SCHEMA = {
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
activities: {
|
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -21,18 +20,26 @@ const PLAN_SCHEMA = {
|
|||||||
name: { type: 'string' },
|
name: { type: 'string' },
|
||||||
description: { type: 'string' },
|
description: { type: 'string' },
|
||||||
durationDays: { type: 'integer', minimum: 1 },
|
durationDays: { type: 'integer', minimum: 1 },
|
||||||
tasks: { type: 'array', items: { type: 'string' } },
|
tasks: {
|
||||||
|
type: 'array',
|
||||||
|
minItems: 2,
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { name: { type: 'string' }, durationDays: { type: 'integer', minimum: 1 } },
|
||||||
|
required: ['name', 'durationDays'],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['name', 'durationDays', 'tasks'],
|
required: ['name', 'durationDays', 'tasks'],
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['activities'],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PLANNER_SYSTEM = (language) => `Eres un jefe de proyectos de TI de una empresa. Solo planificas proyectos: propones actividades realistas en orden de ejecucion, cada una con duracion en dias habiles y de 2 a ${MAX_TASKS} tareas concretas y accionables (las tareas son lo mas importante: ninguna actividad sin tareas). Responde en ${language}.`;
|
||||||
|
const languageFor = (lang) => (String(lang || '').startsWith('en') ? 'English' : 'español neutro');
|
||||||
|
|
||||||
export async function getStatus() {
|
export async function getStatus() {
|
||||||
const { configured } = await getAiConfig();
|
const { configured } = await getAiConfig();
|
||||||
return { enabled: configured };
|
return { enabled: configured, minObjectiveLength: MIN_OBJECTIVE_LENGTH };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getWritableProject(projectId, user) {
|
async function getWritableProject(projectId, user) {
|
||||||
@@ -41,68 +48,195 @@ async function getWritableProject(projectId, user) {
|
|||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normaliza lo que devuelve la IA: recorta, limita cantidades y descarta entradas vacias.
|
const clip = (v, max) => String(v || '').trim().slice(0, max);
|
||||||
|
const days = (v) => Math.min(365, Math.max(1, Number.parseInt(v, 10) || 1));
|
||||||
|
|
||||||
|
// Normaliza lo que devuelve la IA (o lo que reenvia el cliente al aplicar): recorta, limita
|
||||||
|
// cantidades y descarta actividades sin tareas. Acepta tareas como texto o { name, durationDays }.
|
||||||
function cleanPlan(activities) {
|
function cleanPlan(activities) {
|
||||||
return (Array.isArray(activities) ? activities : []).slice(0, MAX_ACTIVITIES).map((a) => ({
|
return (Array.isArray(activities) ? activities : []).slice(0, MAX_ACTIVITIES).map((a) => {
|
||||||
name: String(a?.name || '').trim().slice(0, 200),
|
const tasks = (Array.isArray(a?.tasks) ? a.tasks : [])
|
||||||
description: String(a?.description || '').trim().slice(0, 1000),
|
.map((t) => (typeof t === 'string' ? { name: t, durationDays: 1 } : t))
|
||||||
durationDays: Math.min(365, Math.max(1, Number.parseInt(a?.durationDays, 10) || 1)),
|
.map((t) => ({ name: clip(t?.name, 200), durationDays: days(t?.durationDays) }))
|
||||||
tasks: (Array.isArray(a?.tasks) ? a.tasks : []).map((t) => String(t || '').trim().slice(0, 200)).filter(Boolean).slice(0, MAX_TASKS),
|
.filter((t) => t.name)
|
||||||
})).filter((a) => a.name);
|
.slice(0, MAX_TASKS);
|
||||||
|
return { name: clip(a?.name, 200), description: clip(a?.description, 1000), durationDays: days(a?.durationDays), tasks };
|
||||||
|
}).filter((a) => a.name && a.tasks.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addDays(date, n) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setDate(d.getDate() + n);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
const isoDate = (d) => d.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Fecha de fin estimada = inicio + suma de duraciones (actividades encadenadas).
|
||||||
|
const planEndDate = (plan, start) => addDays(start, Math.max(1, plan.reduce((s, a) => s + a.durationDays, 0)) - 1);
|
||||||
|
|
||||||
|
// Crea actividades encadenadas desde `start` y sus tareas repartidas dentro de cada actividad,
|
||||||
|
// con la prioridad media y el primer estado abierto. Reusa los servicios normales (permisos,
|
||||||
|
// auditoria, notificaciones y recalculo de avance incluidos).
|
||||||
|
async function createPlanActivities(projectId, plan, start, user, responsibleUserId = null) {
|
||||||
|
const [priorities] = await pool.query('SELECT id FROM priorities ORDER BY level, id');
|
||||||
|
const [statuses] = await pool.query('SELECT id FROM activity_statuses WHERE is_closed = 0 ORDER BY sort_order, id LIMIT 1');
|
||||||
|
if (!priorities.length || !statuses.length) throw new HttpError(400, 'Faltan prioridades o estados de actividad en Configuracion');
|
||||||
|
const priorityId = priorities[Math.floor((priorities.length - 1) / 2)].id;
|
||||||
|
let cursor = new Date(start);
|
||||||
|
for (const a of plan) {
|
||||||
|
const due = addDays(cursor, a.durationDays - 1);
|
||||||
|
const activity = await createActivity(projectId, {
|
||||||
|
name: a.name, description: a.description, priorityId, statusId: statuses[0].id,
|
||||||
|
responsibleUserId, startDate: isoDate(cursor), dueDate: isoDate(due),
|
||||||
|
}, user);
|
||||||
|
let taskCursor = new Date(cursor);
|
||||||
|
for (const task of a.tasks) {
|
||||||
|
let taskDue = addDays(taskCursor, task.durationDays - 1);
|
||||||
|
if (taskDue > due) taskDue = due;
|
||||||
|
await createTask(activity.id, { name: task.name, responsibleUserId, dueDate: isoDate(taskDue) }, user);
|
||||||
|
taskCursor = taskDue < due ? addDays(taskDue, 1) : due;
|
||||||
|
}
|
||||||
|
cursor = addDays(due, 1);
|
||||||
|
}
|
||||||
|
return plan.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Plan para un proyecto existente (pestaña Actividades) ---
|
||||||
|
|
||||||
export async function generateProjectPlan(projectId, description, lang, user) {
|
export async function generateProjectPlan(projectId, description, lang, user) {
|
||||||
const project = await getWritableProject(projectId, user);
|
const project = await getWritableProject(projectId, user);
|
||||||
const extra = String(description || '').trim().slice(0, 2000);
|
const extra = clip(description, MAX_OBJECTIVE_LENGTH);
|
||||||
const [existing] = await pool.query('SELECT name FROM activities WHERE project_id = ? AND deleted_at IS NULL', [projectId]);
|
const [existing] = await pool.query('SELECT name FROM activities WHERE project_id = ? AND deleted_at IS NULL', [projectId]);
|
||||||
const language = String(lang || '').startsWith('en') ? 'English' : 'español neutro';
|
|
||||||
const result = await askStructured({
|
const result = await askStructured({
|
||||||
system: `Eres un jefe de proyectos de TI. Propones planes de trabajo realistas y concretos para un area de TI corporativa. Responde en ${language}.`,
|
system: PLANNER_SYSTEM(languageFor(lang)),
|
||||||
prompt: [
|
prompt: [
|
||||||
`Proyecto: ${project.name}`,
|
`Proyecto: ${project.name}`,
|
||||||
project.description ? `Descripcion: ${project.description}` : '',
|
project.description ? `Descripcion: ${project.description}` : '',
|
||||||
project.objective ? `Objetivo: ${project.objective}` : '',
|
project.objective ? `Objetivo: ${project.objective}` : '',
|
||||||
extra ? `Indicaciones del usuario: ${extra}` : '',
|
extra ? `Indicaciones del usuario: ${extra}` : '',
|
||||||
existing.length ? `Actividades que YA existen (no las repitas): ${existing.map((r) => r.name).join('; ')}` : '',
|
existing.length ? `Actividades que YA existen (no las repitas): ${existing.map((r) => r.name).join('; ')}` : '',
|
||||||
`Propon entre 3 y ${MAX_ACTIVITIES} actividades en orden de ejecucion, cada una con una duracion estimada en dias habiles y de 2 a ${MAX_TASKS} tareas concretas.`,
|
`Propon entre 3 y ${MAX_ACTIVITIES} actividades nuevas.`,
|
||||||
].filter(Boolean).join('\n'),
|
].filter(Boolean).join('\n'),
|
||||||
toolName: 'plan_de_proyecto',
|
toolName: 'plan_de_proyecto',
|
||||||
schema: PLAN_SCHEMA,
|
schema: { type: 'object', properties: { activities: ACTIVITIES_SCHEMA }, required: ['activities'] },
|
||||||
maxTokens: 4000,
|
maxTokens: 4000,
|
||||||
});
|
});
|
||||||
await logAudit({ userId: user.id, action: 'ai_plan', entityType: 'project', entityId: projectId });
|
await logAudit({ userId: user.id, action: 'ai_plan', entityType: 'project', entityId: projectId });
|
||||||
return { activities: cleanPlan(result.activities) };
|
return { activities: cleanPlan(result.activities) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function addDays(date, days) {
|
|
||||||
const d = new Date(date);
|
|
||||||
d.setDate(d.getDate() + days);
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
const isoDate = (d) => d.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
// Crea las actividades/tareas que el usuario acepto, encadenadas desde hoy, con la prioridad
|
|
||||||
// media y el primer estado abierto del catalogo. Reusa los servicios normales (permisos,
|
|
||||||
// auditoria, notificaciones y recalculo de avance incluidos).
|
|
||||||
export async function applyProjectPlan(projectId, activities, user) {
|
export async function applyProjectPlan(projectId, activities, user) {
|
||||||
await getWritableProject(projectId, user);
|
const project = await getWritableProject(projectId, user);
|
||||||
const plan = cleanPlan(activities);
|
const plan = cleanPlan(activities);
|
||||||
if (!plan.length) throw new HttpError(400, 'No hay actividades para crear');
|
if (!plan.length) throw new HttpError(400, 'No hay actividades para crear');
|
||||||
const [priorities] = await pool.query('SELECT id FROM priorities ORDER BY sort_order, id');
|
// Desde hoy, o desde el inicio del proyecto si todavia no empezo.
|
||||||
const [statuses] = await pool.query('SELECT id FROM activity_statuses WHERE is_closed = 0 ORDER BY sort_order, id LIMIT 1');
|
const today = new Date();
|
||||||
if (!priorities.length || !statuses.length) throw new HttpError(400, 'Faltan prioridades o estados de actividad en Configuracion');
|
const projectStart = project.start_date ? new Date(project.start_date) : null;
|
||||||
const priorityId = priorities[Math.floor((priorities.length - 1) / 2)].id;
|
const start = projectStart && projectStart > today ? projectStart : today;
|
||||||
let start = new Date();
|
return { created: await createPlanActivities(projectId, plan, start, user) };
|
||||||
let created = 0;
|
}
|
||||||
for (const a of plan) {
|
|
||||||
const due = addDays(start, a.durationDays - 1);
|
// --- Asistente "Nuevo proyecto con IA" ---
|
||||||
const activity = await createActivity(projectId, {
|
// Solo el objetivo va a la IA (una llamada). Estado, responsable, miembros y fecha los fija la
|
||||||
name: a.name, description: a.description, priorityId, statusId: statuses[0].id,
|
// app; el departamento se elige de la lista real. Nada se crea hasta que el usuario confirma.
|
||||||
startDate: isoDate(start), dueDate: isoDate(due),
|
|
||||||
}, user);
|
const PROJECT_CREATOR_ROLES = ['superadmin', 'admin', 'it_manager', 'project_lead'];
|
||||||
for (const name of a.tasks) await createTask(activity.id, { name }, user);
|
|
||||||
start = addDays(due, 1);
|
function requireCreator(user) {
|
||||||
created += 1;
|
if (!PROJECT_CREATOR_ROLES.includes(user.roleCode)) throw new HttpError(403, 'No tienes permisos para crear proyectos');
|
||||||
}
|
}
|
||||||
return { created };
|
|
||||||
|
function parseStartDate(value) {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || ''))) throw new HttpError(400, 'Fecha de inicio invalida');
|
||||||
|
const d = new Date(`${value}T12:00:00`);
|
||||||
|
if (Number.isNaN(d.getTime())) throw new HttpError(400, 'Fecha de inicio invalida');
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjective(value) {
|
||||||
|
const objective = clip(value, MAX_OBJECTIVE_LENGTH);
|
||||||
|
if (objective.length < MIN_OBJECTIVE_LENGTH) throw new HttpError(400, `Describe el objetivo con al menos ${MIN_OBJECTIVE_LENGTH} caracteres`);
|
||||||
|
return objective;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function proposeNewProject({ objective, startDate, lang }, user) {
|
||||||
|
requireCreator(user);
|
||||||
|
const goal = parseObjective(objective);
|
||||||
|
const start = parseStartDate(startDate);
|
||||||
|
const [types] = await pool.query('SELECT id, name FROM project_types ORDER BY name');
|
||||||
|
const typeNames = types.map((t) => t.name);
|
||||||
|
const result = await askStructured({
|
||||||
|
system: `${PLANNER_SYSTEM(languageFor(lang))} Si el texto del usuario NO describe un proyecto o iniciativa de trabajo (saludos, preguntas generales, chistes, temas ajenos), devuelve isProject=false y no inventes nada.`,
|
||||||
|
prompt: [
|
||||||
|
`Objetivo del proyecto descrito por el usuario: """${goal}"""`,
|
||||||
|
`Fecha de inicio: ${isoDate(start)}`,
|
||||||
|
typeNames.length ? `Tipos de proyecto disponibles (elige uno exacto): ${typeNames.join('; ')}` : '',
|
||||||
|
`Si es un proyecto: propone un nombre corto (max 80 caracteres); un objetivo (una sola oracion que empiece con verbo en infinitivo y diga QUE se quiere lograr y para que, max 250 caracteres); una descripcion (alcance y contexto: que incluye y como se hara, 2-3 oraciones); y entre 3 y ${MAX_ACTIVITIES} actividades.`,
|
||||||
|
].filter(Boolean).join('\n'),
|
||||||
|
toolName: 'propuesta_de_proyecto',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
isProject: { type: 'boolean' },
|
||||||
|
name: { type: 'string' },
|
||||||
|
objective: { type: 'string' },
|
||||||
|
description: { type: 'string' },
|
||||||
|
projectType: typeNames.length ? { type: 'string', enum: typeNames } : { type: 'string' },
|
||||||
|
activities: ACTIVITIES_SCHEMA,
|
||||||
|
},
|
||||||
|
required: ['isProject'],
|
||||||
|
},
|
||||||
|
maxTokens: 4000,
|
||||||
|
});
|
||||||
|
await logAudit({ userId: user.id, action: 'ai_new_project', entityType: 'project', entityId: null });
|
||||||
|
if (!result.isProject) throw new HttpError(422, 'Solo puedo ayudarte a crear proyectos. Describe el objetivo de un proyecto de trabajo.');
|
||||||
|
const plan = cleanPlan(result.activities);
|
||||||
|
if (!plan.length) throw new HttpError(422, 'No se pudo generar un plan con tareas para ese objetivo. Intenta describirlo con mas detalle.');
|
||||||
|
const type = types.find((t) => t.name === result.projectType);
|
||||||
|
return {
|
||||||
|
name: clip(result.name, 80) || goal.slice(0, 80),
|
||||||
|
// El texto del usuario suele ser corto: la IA lo redacta como objetivo y deja el alcance en la descripcion.
|
||||||
|
objective: clip(result.objective, 500) || goal,
|
||||||
|
description: clip(result.description, 1000),
|
||||||
|
projectTypeId: type?.id || null,
|
||||||
|
projectTypeName: type?.name || null,
|
||||||
|
estimatedEndDate: isoDate(planEndDate(plan, start)),
|
||||||
|
activities: plan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNewProject(payload, user) {
|
||||||
|
requireCreator(user);
|
||||||
|
const objective = parseObjective(payload.objective);
|
||||||
|
const start = parseStartDate(payload.startDate);
|
||||||
|
const name = clip(payload.name, 200);
|
||||||
|
if (!name) throw new HttpError(400, 'El nombre es obligatorio');
|
||||||
|
const plan = cleanPlan(payload.activities);
|
||||||
|
if (!plan.length) throw new HttpError(400, 'Selecciona al menos una actividad con tareas');
|
||||||
|
const departmentId = Number(payload.requestingDepartmentId);
|
||||||
|
const [[department]] = await pool.query('SELECT id FROM departments WHERE id = ?', [departmentId]);
|
||||||
|
if (!department) throw new HttpError(400, 'Departamento solicitante invalido');
|
||||||
|
const [[type]] = payload.projectTypeId ? await pool.query('SELECT id FROM project_types WHERE id = ?', [Number(payload.projectTypeId)]) : [[null]];
|
||||||
|
const [priorities] = await pool.query('SELECT id FROM priorities ORDER BY level, id');
|
||||||
|
// Estado "Planificado" si existe; si no, el primer estado abierto.
|
||||||
|
const [statuses] = await pool.query(
|
||||||
|
"SELECT id FROM project_statuses WHERE is_closed = 0 ORDER BY (name LIKE 'Planific%' OR name LIKE 'Plann%') DESC, sort_order, id LIMIT 1"
|
||||||
|
);
|
||||||
|
if (!priorities.length || !statuses.length) throw new HttpError(400, 'Faltan prioridades o estados de proyecto en Configuracion');
|
||||||
|
|
||||||
|
const project = await createProject({
|
||||||
|
name,
|
||||||
|
description: clip(payload.description, 1000),
|
||||||
|
objective,
|
||||||
|
projectTypeId: type?.id || null,
|
||||||
|
priorityId: priorities[Math.floor((priorities.length - 1) / 2)].id,
|
||||||
|
statusId: statuses[0].id,
|
||||||
|
ownerUserId: user.id,
|
||||||
|
requestingDepartmentId: department.id,
|
||||||
|
startDate: isoDate(start),
|
||||||
|
estimatedEndDate: isoDate(planEndDate(plan, start)),
|
||||||
|
}, user);
|
||||||
|
await createPlanActivities(project.id, plan, start, user, user.id);
|
||||||
|
await logAudit({ userId: user.id, action: 'ai_create_project', entityType: 'project', entityId: project.id });
|
||||||
|
return { id: project.id, code: project.code };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.2.0",
|
"version": "4.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -26,10 +26,24 @@ export const messages = {
|
|||||||
ai_plan_generating: 'Generando...',
|
ai_plan_generating: 'Generando...',
|
||||||
ai_plan_review: 'Desmarca lo que no quieras. Las actividades se crean encadenadas desde hoy, con prioridad media; luego puedes editarlas.',
|
ai_plan_review: 'Desmarca lo que no quieras. Las actividades se crean encadenadas desde hoy, con prioridad media; luego puedes editarlas.',
|
||||||
ai_plan_days: 'días',
|
ai_plan_days: 'días',
|
||||||
|
ai_plan_day: 'día',
|
||||||
ai_plan_apply: 'Crear seleccionadas',
|
ai_plan_apply: 'Crear seleccionadas',
|
||||||
ai_plan_creating: 'Creando...',
|
ai_plan_creating: 'Creando...',
|
||||||
ai_plan_back: 'Volver',
|
ai_plan_back: 'Volver',
|
||||||
ai_plan_created: 'Actividades creadas',
|
ai_plan_created: 'Actividades creadas',
|
||||||
|
ai_new_project_button: 'Nuevo con IA',
|
||||||
|
ai_new_project_title: 'Nuevo proyecto con IA',
|
||||||
|
ai_new_project_objective: '¿Qué proyecto quieres emprender? Describe su objetivo',
|
||||||
|
ai_new_project_min_chars: 'caracteres mínimos',
|
||||||
|
ai_new_project_placeholder: 'Ej.: implementar una mesa de ayuda para que los usuarios reporten incidencias de TI con seguimiento y tiempos de respuesta',
|
||||||
|
ai_new_project_start: 'Fecha de inicio',
|
||||||
|
ai_new_project_fixed: 'El proyecto se crea en estado Planificado, contigo como responsable y único miembro. La IA propone el nombre, las actividades y sus tareas; podrás revisarlos antes de crear.',
|
||||||
|
ai_new_project_type: 'Tipo',
|
||||||
|
ai_new_project_goal: 'Objetivo',
|
||||||
|
ai_new_project_desc: 'Descripción',
|
||||||
|
ai_new_project_end: 'Fin estimado',
|
||||||
|
ai_new_project_review: 'Desmarca las actividades que no quieras. Puedes cambiar el nombre aquí o después en el proyecto.',
|
||||||
|
ai_new_project_create: 'Crear proyecto',
|
||||||
nav_projects: 'Proyectos',
|
nav_projects: 'Proyectos',
|
||||||
nav_organization: 'Organizacion',
|
nav_organization: 'Organizacion',
|
||||||
nav_config: 'Configuracion',
|
nav_config: 'Configuracion',
|
||||||
@@ -573,10 +587,24 @@ export const messages = {
|
|||||||
ai_plan_generating: 'Generating...',
|
ai_plan_generating: 'Generating...',
|
||||||
ai_plan_review: 'Uncheck what you do not want. Activities are created back to back from today with medium priority; you can edit them later.',
|
ai_plan_review: 'Uncheck what you do not want. Activities are created back to back from today with medium priority; you can edit them later.',
|
||||||
ai_plan_days: 'days',
|
ai_plan_days: 'days',
|
||||||
|
ai_plan_day: 'day',
|
||||||
ai_plan_apply: 'Create selected',
|
ai_plan_apply: 'Create selected',
|
||||||
ai_plan_creating: 'Creating...',
|
ai_plan_creating: 'Creating...',
|
||||||
ai_plan_back: 'Back',
|
ai_plan_back: 'Back',
|
||||||
ai_plan_created: 'Activities created',
|
ai_plan_created: 'Activities created',
|
||||||
|
ai_new_project_button: 'New with AI',
|
||||||
|
ai_new_project_title: 'New project with AI',
|
||||||
|
ai_new_project_objective: 'What project do you want to start? Describe its goal',
|
||||||
|
ai_new_project_min_chars: 'minimum characters',
|
||||||
|
ai_new_project_placeholder: 'E.g.: implement a help desk so users can report IT incidents with tracking and response times',
|
||||||
|
ai_new_project_start: 'Start date',
|
||||||
|
ai_new_project_fixed: 'The project is created as Planned, with you as owner and only member. AI proposes the name, activities and tasks; you will review them before creating.',
|
||||||
|
ai_new_project_type: 'Type',
|
||||||
|
ai_new_project_goal: 'Goal',
|
||||||
|
ai_new_project_desc: 'Description',
|
||||||
|
ai_new_project_end: 'Estimated end',
|
||||||
|
ai_new_project_review: 'Uncheck the activities you do not want. You can change the name here or later in the project.',
|
||||||
|
ai_new_project_create: 'Create project',
|
||||||
nav_projects: 'Projects',
|
nav_projects: 'Projects',
|
||||||
nav_organization: 'Organization',
|
nav_organization: 'Organization',
|
||||||
nav_config: 'Settings',
|
nav_config: 'Settings',
|
||||||
|
|||||||
@@ -73,11 +73,11 @@ export function AiPlanDialog({ open, projectId, onClose, onCreated }) {
|
|||||||
<label key={i} className="panel-card" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', margin: 0 }}>
|
<label key={i} className="panel-card" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', margin: 0 }}>
|
||||||
<input type="checkbox" checked={selected.includes(i)} onChange={() => toggle(i)} style={{ marginTop: 4 }} />
|
<input type="checkbox" checked={selected.includes(i)} onChange={() => toggle(i)} style={{ marginTop: 4 }} />
|
||||||
<div>
|
<div>
|
||||||
<strong>{a.name}</strong> <span className="text-muted">· {a.durationDays} {t('ai_plan_days')}</span>
|
<strong>{a.name}</strong> <span className="text-muted">· {a.durationDays} {t(a.durationDays === 1 ? 'ai_plan_day' : 'ai_plan_days')}</span>
|
||||||
{a.description && <div className="text-muted" style={{ fontSize: '.8rem' }}>{a.description}</div>}
|
{a.description && <div className="text-muted" style={{ fontSize: '.8rem' }}>{a.description}</div>}
|
||||||
{a.tasks.length > 0 && (
|
{a.tasks.length > 0 && (
|
||||||
<ul style={{ margin: '4px 0 0', paddingLeft: 18, fontSize: '.82rem' }}>
|
<ul style={{ margin: '4px 0 0', paddingLeft: 18, fontSize: '.82rem' }}>
|
||||||
{a.tasks.map((task, j) => <li key={j}>{task}</li>)}
|
{a.tasks.map((task, j) => <li key={j}>{task.name} <span className="text-muted">({task.durationDays} {t(task.durationDays === 1 ? 'ai_plan_day' : 'ai_plan_days')})</span></li>)}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Sparkles, Check, ArrowLeft } from 'lucide-react';
|
||||||
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||||
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
|
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
|
import { proposeNewProject, createNewProject } from '../../services/ai.js';
|
||||||
|
|
||||||
|
const today = () => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Asistente por pasos: 1) objetivo + departamento + fecha (validados aqui, antes de gastar tokens);
|
||||||
|
// 2) una sola llamada a la IA; 3) revision y creacion. Estado, responsable y miembros los fija el
|
||||||
|
// backend (Planificado / quien crea). Si el texto no es un proyecto, la IA lo rechaza.
|
||||||
|
export function NewProjectAiDialog({ open, deptOptions, minLength = 20, onClose, onCreated }) {
|
||||||
|
const { t, lang } = useUi();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [objective, setObjective] = useState('');
|
||||||
|
const [departmentId, setDepartmentId] = useState(user?.departmentId || '');
|
||||||
|
const [startDate, setStartDate] = useState(today());
|
||||||
|
const [proposal, setProposal] = useState(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [selected, setSelected] = useState([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const objectiveOk = objective.trim().length >= minLength;
|
||||||
|
const dateOk = /^\d{4}-\d{2}-\d{2}$/.test(startDate);
|
||||||
|
const canGenerate = objectiveOk && departmentId && dateOk && !busy;
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (busy) return;
|
||||||
|
setProposal(null); setSelected([]); setError(''); setObjective(''); setName(''); setStartDate(today());
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
const p = await proposeNewProject({ objective, startDate, lang });
|
||||||
|
setProposal(p);
|
||||||
|
setName(p.name);
|
||||||
|
setSelected(p.activities.map((_, i) => i));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
const r = await createNewProject({
|
||||||
|
objective: proposal.objective || objective, startDate, name, description: proposal.description, projectTypeId: proposal.projectTypeId,
|
||||||
|
requestingDepartmentId: departmentId, activities: proposal.activities.filter((_, i) => selected.includes(i)),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
close();
|
||||||
|
onCreated(r);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (i) => setSelected((s) => (s.includes(i) ? s.filter((x) => x !== i) : [...s, i]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailPopup open={open} title={t('ai_new_project_title')} onClose={close} width={760} height={680}>
|
||||||
|
{error && <InfoBar tone="error">{error}</InfoBar>}
|
||||||
|
{!proposal ? (
|
||||||
|
<>
|
||||||
|
<Field label={t('ai_new_project_objective')} hint={`${objective.trim().length}/${minLength} ${t('ai_new_project_min_chars')}`}>
|
||||||
|
<textarea className="input" rows={5} maxLength={2000} value={objective} onChange={(e) => setObjective(e.target.value)} placeholder={t('ai_new_project_placeholder')} autoFocus />
|
||||||
|
</Field>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('project_requesting_department')}>
|
||||||
|
<ComboBox options={deptOptions} value={departmentId} onChange={setDepartmentId} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('ai_new_project_start')}>
|
||||||
|
<input className="input" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</FieldRow>
|
||||||
|
<p className="text-muted" style={{ fontSize: '.8rem' }}>{t('ai_new_project_fixed')}</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button variant="accent" icon={Sparkles} onClick={generate} disabled={!canGenerate}>{busy ? t('ai_plan_generating') : t('ai_plan_generate')}</Button>
|
||||||
|
<Button variant="standard" onClick={close} disabled={busy}>{t('common_cancel')}</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Field label={t('common_name')}>
|
||||||
|
<input className="input" value={name} maxLength={200} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<p className="text-muted" style={{ marginTop: 0, fontSize: '.8rem' }}>
|
||||||
|
{proposal.projectTypeName && <>{t('ai_new_project_type')}: <strong>{proposal.projectTypeName}</strong> · </>}
|
||||||
|
{t('ai_new_project_end')}: <strong>{proposal.estimatedEndDate}</strong>
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: '.85rem', marginBottom: 4 }}><strong>{t('ai_new_project_goal')}:</strong> {proposal.objective}</p>
|
||||||
|
{proposal.description && <p style={{ fontSize: '.85rem', marginTop: 0 }}><strong>{t('ai_new_project_desc')}:</strong> {proposal.description}</p>}
|
||||||
|
<p className="text-muted" style={{ fontSize: '.8rem' }}>{t('ai_new_project_review')}</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 16 }}>
|
||||||
|
{proposal.activities.map((a, i) => (
|
||||||
|
<label key={i} className="panel-card" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', margin: 0 }}>
|
||||||
|
<input type="checkbox" checked={selected.includes(i)} onChange={() => toggle(i)} style={{ marginTop: 4 }} />
|
||||||
|
<div>
|
||||||
|
<strong>{a.name}</strong> <span className="text-muted">· {a.durationDays} {t(a.durationDays === 1 ? 'ai_plan_day' : 'ai_plan_days')}</span>
|
||||||
|
<ul style={{ margin: '4px 0 0', paddingLeft: 18, fontSize: '.82rem' }}>
|
||||||
|
{a.tasks.map((task, j) => <li key={j}>{task.name} <span className="text-muted">({task.durationDays} {t(task.durationDays === 1 ? 'ai_plan_day' : 'ai_plan_days')})</span></li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button variant="accent" icon={Check} onClick={create} disabled={busy || !selected.length || !name.trim()}>
|
||||||
|
{busy ? t('ai_plan_creating') : t('ai_new_project_create')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="standard" icon={ArrowLeft} onClick={() => setProposal(null)} disabled={busy}>{t('ai_plan_back')}</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DetailPopup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Plus, Save, Trash2, RotateCcw, ArrowLeft } from 'lucide-react';
|
import { Plus, Save, Trash2, RotateCcw, ArrowLeft, Sparkles } from 'lucide-react';
|
||||||
|
import { NewProjectAiDialog } from './NewProjectAiDialog.jsx';
|
||||||
|
import { getAiStatus } from '../../services/ai.js';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
@@ -88,6 +90,10 @@ export default function ProyectosPage() {
|
|||||||
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
|
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
|
||||||
const { data: types } = useAsync(() => listCatalog('project-types'), []);
|
const { data: types } = useAsync(() => listCatalog('project-types'), []);
|
||||||
const { data: deptTree } = useAsync(listDepartments, []);
|
const { data: deptTree } = useAsync(listDepartments, []);
|
||||||
|
// Asistente con IA: solo si el superadmin configuro la clave y el rol puede crear proyectos.
|
||||||
|
const { data: aiStatus } = useAsync(() => getAiStatus().catch(() => ({ enabled: false })), []);
|
||||||
|
const [aiCreating, setAiCreating] = useState(false);
|
||||||
|
const canCreateProjects = ['superadmin', 'admin', 'it_manager', 'project_lead'].includes(currentUser?.roleCode);
|
||||||
|
|
||||||
useEffect(() => { setPage(1); }, [showTrash, filterStatusId, filterPriorityId, filterOwnerUserId, filterDueSoon, progressFilter, search, pageSize]);
|
useEffect(() => { setPage(1); }, [showTrash, filterStatusId, filterPriorityId, filterOwnerUserId, filterDueSoon, progressFilter, search, pageSize]);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
@@ -224,6 +230,9 @@ export default function ProyectosPage() {
|
|||||||
{isSuperadmin && (
|
{isSuperadmin && (
|
||||||
<Button variant="standard" icon={Trash2} onClick={() => setShowTrash(true)}>{t('project_trash')}</Button>
|
<Button variant="standard" icon={Trash2} onClick={() => setShowTrash(true)}>{t('project_trash')}</Button>
|
||||||
)}
|
)}
|
||||||
|
{aiStatus?.enabled && canCreateProjects && (
|
||||||
|
<Button variant="standard" icon={Sparkles} onClick={() => setAiCreating(true)}>{t('ai_new_project_button')}</Button>
|
||||||
|
)}
|
||||||
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}{pendingNew && !creating && <DraftMark />}</Button>
|
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}{pendingNew && !creating && <DraftMark />}</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -315,6 +324,14 @@ export default function ProyectosPage() {
|
|||||||
<Button variant="standard" disabled={page * pageSize >= projectPage.total} onClick={() => setPage((p) => p + 1)}>{t('project_page_next')}</Button>
|
<Button variant="standard" disabled={page * pageSize >= projectPage.total} onClick={() => setPage((p) => p + 1)}>{t('project_page_next')}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NewProjectAiDialog
|
||||||
|
open={aiCreating}
|
||||||
|
deptOptions={deptOptions}
|
||||||
|
minLength={aiStatus?.minObjectiveLength}
|
||||||
|
onClose={() => setAiCreating(false)}
|
||||||
|
onCreated={(r) => navigate(`/proyectos/${r.id}`)}
|
||||||
|
/>
|
||||||
|
|
||||||
<DetailPopup open={creating} title={t('project_new')} onClose={() => guardedClose(draft, () => setCreating(false))} width={720} height={720}>
|
<DetailPopup open={creating} title={t('project_new')} onClose={() => guardedClose(draft, () => setCreating(false))} width={720} height={720}>
|
||||||
<DraftNotice draft={draft} />
|
<DraftNotice draft={draft} />
|
||||||
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
|
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ import { api } from './api.js';
|
|||||||
export const getAiStatus = () => api.get('/ai/status');
|
export const getAiStatus = () => api.get('/ai/status');
|
||||||
export const generateProjectPlan = (projectId, data) => api.post(`/ai/projects/${projectId}/plan`, data);
|
export const generateProjectPlan = (projectId, data) => api.post(`/ai/projects/${projectId}/plan`, data);
|
||||||
export const applyProjectPlan = (projectId, activities) => api.post(`/ai/projects/${projectId}/plan/apply`, { activities });
|
export const applyProjectPlan = (projectId, activities) => api.post(`/ai/projects/${projectId}/plan/apply`, { activities });
|
||||||
|
export const proposeNewProject = (data) => api.post('/ai/new-project/propose', data);
|
||||||
|
export const createNewProject = (data) => api.post('/ai/new-project', data);
|
||||||
|
|||||||
Reference in New Issue
Block a user