From 89dec6da7e970fdd05e99e87abb8ecb97349a58e Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Fri, 23 Oct 2026 15:55:54 -0500 Subject: [PATCH] feat: version 3.2.0 -- Integracion IA: configuracion y plan de actividades Co-Authored-By: Claude Opus 5.5 --- API.md | 6 + BASE_DE_DATOS.md | 1 + CHANGELOG.md | 13 +++ ESTADO.md | 5 +- VERSION | 2 +- .../database/migrations/031_ai_settings.sql | 3 + backend/package.json | 2 +- backend/src/app.js | 2 + backend/src/modules/ai/controller.js | 12 ++ backend/src/modules/ai/routes.js | 17 +++ backend/src/modules/ai/service.js | 108 ++++++++++++++++++ backend/src/modules/settings/controller.js | 10 ++ backend/src/modules/settings/routes.js | 5 +- backend/src/modules/settings/service.js | 56 +++++++++ backend/src/utils/ai.js | 47 ++++++++ frontend/package.json | 2 +- frontend/src/components/PasswordInput.jsx | 5 +- frontend/src/i18n/messages.js | 48 ++++++++ .../pages/Configuracion/AiSettingsPanel.jsx | 78 +++++++++++++ .../pages/Configuracion/ConfiguracionPage.jsx | 7 +- .../pages/Configuracion/SmtpSettingsPanel.jsx | 2 +- frontend/src/pages/Proyectos/AiPlanDialog.jsx | 97 ++++++++++++++++ .../pages/Proyectos/ProjectActivitiesTab.jsx | 12 +- frontend/src/services/ai.js | 5 + frontend/src/services/settings.js | 3 + 25 files changed, 538 insertions(+), 10 deletions(-) create mode 100644 backend/database/migrations/031_ai_settings.sql create mode 100644 backend/src/modules/ai/controller.js create mode 100644 backend/src/modules/ai/routes.js create mode 100644 backend/src/modules/ai/service.js create mode 100644 backend/src/utils/ai.js create mode 100644 frontend/src/pages/Configuracion/AiSettingsPanel.jsx create mode 100644 frontend/src/pages/Proyectos/AiPlanDialog.jsx create mode 100644 frontend/src/services/ai.js diff --git a/API.md b/API.md index 2105f2c..3e82180 100644 --- a/API.md +++ b/API.md @@ -178,6 +178,12 @@ el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`. `backend/database/migrations/` locales contra los de `main`. Es solo informativo — no aplica ningún cambio, la actualización real sigue siendo manual. +### IA (`/api/ai`) — requiere clave configurada por el superadmin +- `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/apply` `{ activities }` → `{ created }` (crea actividades y tareas). +- `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`) - `POST /api/error-logs` — público (sin auth, rate-limited a 30/15min), lo usa el frontend para reportar errores propios (incluso sin sesión). `{ message, stack?, url?, severity? }` diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 9ce8f24..1815cd9 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -46,6 +46,7 @@ | `device_cpus` / `device_memories` / `device_disks` / `device_volumes` / `device_networks` / `device_monitors` / `device_software` | componentes de cada equipo, reemplazados completos en cada reporte | | `device_accounts` | cuentas que reporta el agente: sesión iniciada, usuarios locales y grupos locales (`kind`), reemplazadas en cada reporte (migración `027`) | | `device_changes` | diff entre reportes (RAM, discos, software instalado/desinstalado) | +| `system_settings` (IA) | `ai_api_key_enc` (AES-256-GCM), `ai_model`, `ai_edit_version` (migración 031) | | `user_modules` | acceso por módulo y usuario (`user_id`, `module`, `enabled`, `updated_by`); sin fila = habilitado (migración 030) | | `device_viewers` | usuarios con acceso de solo lectura compartido al menú Equipos (`user_id`, `granted_by`) | | `inventory_tokens` | tokens de ingesta del agente (solo hash sha256) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 9df8d72..e4d1ae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog - project.linkedcorp.net +## [3.2.0] - 2026-10-23 +### Added +- Integración IA (API de Anthropic): Configuración → "Inteligencia artificial (IA)", solo + superadmin. Clave cifrada AES-256-GCM en `system_settings` (migración 031), modelo Claude + Haiku 5.5 (económico, por defecto) o Sonnet 5.5, y botón "Probar conexión". +- "Plan con IA" en la pestaña Actividades de un proyecto: la IA propone actividades con duración + y tareas a partir del nombre/descripción/objetivo; el usuario revisa, desmarca y recién ahí se + crean (encadenadas desde hoy, prioridad media, primer estado abierto) con los servicios normales. +- Tope de 30 planes por hora por usuario, auditoría (`ai_plan`) y log de tokens por llamada. +### Fixed +- Campos de clave de SMTP/IA con `autocomplete="new-password"`: el gestor de contraseñas del + navegador ya no rellena la clave de login y deja el formulario "con cambios sin guardar". + ## [3.1.2] - 2026-10-23 ### Fixed - Panel: un usuario sin el módulo Proyectos ya no ve KPIs ni detalle de proyectos; ve solo una diff --git a/ESTADO.md b/ESTADO.md index 3120555..5810cc9 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -174,8 +174,9 @@ y recargar. Pendiente verificar el flujo completo en un despliegue autorizado; n - 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 + roles fijos codificados). -- Integracion IA (API Claude) — ideas: plan de proyecto generado desde una descripcion, resumen - semanal del Panel. Clave cifrada en `system_settings` como el SMTP; la IA solo sugiere. +- IA (v3.2.0 hecho: config + "Plan con IA" en Actividades). Siguiente: asistente "Nuevo proyecto con + IA" por pasos (objetivo -> datos fijos/elegidos -> una llamada -> revision), fechas del proyecto, + miembros/responsables y tareas con fechas. Despues: resumen semanal del Panel. - Notificaciones por email/WhatsApp (hoy: solo in-app). - Job programado para detectar actividades vencidas/por vencer (hoy: solo se notifica al asignar). - DatePicker: sigue usando `` nativo (simplificacion deliberada del MVP). diff --git a/VERSION b/VERSION index ef538c2..944880f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.2 +3.2.0 diff --git a/backend/database/migrations/031_ai_settings.sql b/backend/database/migrations/031_ai_settings.sql new file mode 100644 index 0000000..d1e4c0d --- /dev/null +++ b/backend/database/migrations/031_ai_settings.sql @@ -0,0 +1,3 @@ +-- Integracion IA (API de Anthropic): clave cifrada AES-256-GCM y modelo en system_settings, igual +-- que el SMTP. Solo se siembra la version de edicion optimista. Idempotente. +INSERT INTO system_settings (`key`, `value`) VALUES ('ai_edit_version', '1') ON DUPLICATE KEY UPDATE `value` = `value`; diff --git a/backend/package.json b/backend/package.json index 72ae212..32261e0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "3.1.2", + "version": "3.2.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index 347998e..4315bbc 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -26,6 +26,7 @@ import notesRoutes from './modules/notes/routes.js'; import vaultRoutes from './modules/vault/routes.js'; import devicesRoutes from './modules/devices/routes.js'; import inventoryIngestRoutes from './modules/devices/ingest.js'; +import aiRoutes from './modules/ai/routes.js'; export const app = express(); @@ -66,6 +67,7 @@ app.use('/api/error-logs', errorLogsRoutes); app.use('/api/notes', notesRoutes); app.use('/api/vault', vaultRoutes); app.use('/api/devices', devicesRoutes); +app.use('/api/ai', aiRoutes); // Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas // especifico registrado despues (ver ESTADO.md, deuda tecnica). diff --git a/backend/src/modules/ai/controller.js b/backend/src/modules/ai/controller.js new file mode 100644 index 0000000..1ca6055 --- /dev/null +++ b/backend/src/modules/ai/controller.js @@ -0,0 +1,12 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getStatus = asyncHandler(async (req, res) => ok(res, await service.getStatus())); + +export const postProjectPlan = asyncHandler(async (req, res) => { + ok(res, await service.generateProjectPlan(Number(req.params.projectId), req.body?.description, req.body?.lang, req.currentUser)); +}); + +export const postApplyProjectPlan = asyncHandler(async (req, res) => { + ok(res, await service.applyProjectPlan(Number(req.params.projectId), req.body?.activities, req.currentUser), 201); +}); diff --git a/backend/src/modules/ai/routes.js b/backend/src/modules/ai/routes.js new file mode 100644 index 0000000..6f43350 --- /dev/null +++ b/backend/src/modules/ai/routes.js @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; +import { requireAuth } from '../../middleware/auth.js'; +import { requireModule } from '../../middleware/modules.js'; +import { getStatus, postProjectPlan, postApplyProjectPlan } from './controller.js'; + +const router = Router(); + +// Cada llamada a la IA cuesta: tope por usuario/IP para no agotar el saldo por error o abuso. +const aiLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 30, standardHeaders: true, legacyHeaders: false }); + +router.use(requireAuth); +router.get('/status', getStatus); +router.post('/projects/:projectId/plan', requireModule('projects'), aiLimiter, postProjectPlan); +router.post('/projects/:projectId/plan/apply', requireModule('projects'), postApplyProjectPlan); + +export default router; diff --git a/backend/src/modules/ai/service.js b/backend/src/modules/ai/service.js new file mode 100644 index 0000000..f5d8a70 --- /dev/null +++ b/backend/src/modules/ai/service.js @@ -0,0 +1,108 @@ +import { pool } from '../../config/db.js'; +import { HttpError } from '../../utils/http.js'; +import { askStructured } from '../../utils/ai.js'; +import { logAudit } from '../../utils/audit.js'; +import { getAiConfig } from '../settings/service.js'; +import { getProject, canWriteProject } from '../projects/service.js'; +import { createActivity } from '../activities/service.js'; +import { createTask } from '../tasks/service.js'; + +const MAX_ACTIVITIES = 15; +const MAX_TASKS = 10; + +const PLAN_SCHEMA = { + type: 'object', + properties: { + activities: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + durationDays: { type: 'integer', minimum: 1 }, + tasks: { type: 'array', items: { type: 'string' } }, + }, + required: ['name', 'durationDays', 'tasks'], + }, + }, + }, + required: ['activities'], +}; + +export async function getStatus() { + const { configured } = await getAiConfig(); + return { enabled: configured }; +} + +async function getWritableProject(projectId, user) { + const project = await getProject(projectId, user); + if (!(await canWriteProject(user, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto'); + return project; +} + +// Normaliza lo que devuelve la IA: recorta, limita cantidades y descarta entradas vacias. +function cleanPlan(activities) { + return (Array.isArray(activities) ? activities : []).slice(0, MAX_ACTIVITIES).map((a) => ({ + name: String(a?.name || '').trim().slice(0, 200), + description: String(a?.description || '').trim().slice(0, 1000), + durationDays: Math.min(365, Math.max(1, Number.parseInt(a?.durationDays, 10) || 1)), + tasks: (Array.isArray(a?.tasks) ? a.tasks : []).map((t) => String(t || '').trim().slice(0, 200)).filter(Boolean).slice(0, MAX_TASKS), + })).filter((a) => a.name); +} + +export async function generateProjectPlan(projectId, description, lang, user) { + const project = await getWritableProject(projectId, user); + const extra = String(description || '').trim().slice(0, 2000); + 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({ + system: `Eres un jefe de proyectos de TI. Propones planes de trabajo realistas y concretos para un area de TI corporativa. Responde en ${language}.`, + prompt: [ + `Proyecto: ${project.name}`, + project.description ? `Descripcion: ${project.description}` : '', + project.objective ? `Objetivo: ${project.objective}` : '', + extra ? `Indicaciones del usuario: ${extra}` : '', + 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.`, + ].filter(Boolean).join('\n'), + toolName: 'plan_de_proyecto', + schema: PLAN_SCHEMA, + maxTokens: 4000, + }); + await logAudit({ userId: user.id, action: 'ai_plan', entityType: 'project', entityId: projectId }); + 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) { + await getWritableProject(projectId, user); + const plan = cleanPlan(activities); + 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'); + 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 start = new Date(); + let created = 0; + for (const a of plan) { + const due = addDays(start, a.durationDays - 1); + const activity = await createActivity(projectId, { + name: a.name, description: a.description, priorityId, statusId: statuses[0].id, + startDate: isoDate(start), dueDate: isoDate(due), + }, user); + for (const name of a.tasks) await createTask(activity.id, { name }, user); + start = addDays(due, 1); + created += 1; + } + return { created }; +} diff --git a/backend/src/modules/settings/controller.js b/backend/src/modules/settings/controller.js index de0372c..fba8e3b 100644 --- a/backend/src/modules/settings/controller.js +++ b/backend/src/modules/settings/controller.js @@ -10,3 +10,13 @@ export const putSmtp = asyncHandler(async (req, res) => { export const postTestSmtp = asyncHandler(async (req, res) => { ok(res, await service.testSmtpConfig(req.body, req.currentUser.id)); }); + +export const getAi = asyncHandler(async (req, res) => ok(res, await service.getAiConfig())); + +export const putAi = asyncHandler(async (req, res) => { + ok(res, await service.updateAiConfig(req.body, req.currentUser.id)); +}); + +export const postTestAi = asyncHandler(async (req, res) => { + ok(res, await service.testAiConfig(req.body, req.currentUser.id)); +}); diff --git a/backend/src/modules/settings/routes.js b/backend/src/modules/settings/routes.js index 312d5e7..327362e 100644 --- a/backend/src/modules/settings/routes.js +++ b/backend/src/modules/settings/routes.js @@ -1,7 +1,7 @@ import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import { requireAuth, requireRole, CAN_MANAGE_SMTP } from '../../middleware/auth.js'; -import { getSmtp, putSmtp, postTestSmtp } from './controller.js'; +import { getSmtp, putSmtp, postTestSmtp, getAi, putAi, postTestAi } from './controller.js'; const router = Router(); @@ -12,5 +12,8 @@ router.use(requireAuth, requireRole(...CAN_MANAGE_SMTP)); router.get('/smtp', getSmtp); router.put('/smtp', putSmtp); router.post('/smtp/test', testLimiter, postTestSmtp); +router.get('/ai', getAi); +router.put('/ai', putAi); +router.post('/ai/test', testLimiter, postTestAi); export default router; diff --git a/backend/src/modules/settings/service.js b/backend/src/modules/settings/service.js index 9718c46..a5fbaeb 100644 --- a/backend/src/modules/settings/service.js +++ b/backend/src/modules/settings/service.js @@ -114,3 +114,59 @@ export async function testSmtpConfig(payload, actorId) { await logAudit({ userId: actorId, action: 'test', entityType: 'settings:smtp', entityId: null, detail: { to } }); return { sent: true }; } + +// --- Integracion IA (Anthropic). La clave nunca sale del backend: solo se informa si existe. --- +export const AI_DEFAULT_MODEL = 'claude-haiku-5-5'; +const AI_MODELS = ['claude-haiku-5-5', 'claude-sonnet-5-5']; + +export async function getAiConfig() { + const values = await repo.getAll(['ai_api_key_enc', 'ai_model', 'ai_edit_version']); + return { + edit_version: Number(values.ai_edit_version), + model: values.ai_model || AI_DEFAULT_MODEL, + models: AI_MODELS, + configured: !!values.ai_api_key_enc, + }; +} + +// Uso interno (utils/ai.js). null si no hay clave o no se puede descifrar. +export async function getAiConfigInternal() { + const values = await repo.getAll(['ai_api_key_enc', 'ai_model']); + if (!values.ai_api_key_enc) return null; + try { + return { apiKey: decrypt(values.ai_api_key_enc), model: values.ai_model || AI_DEFAULT_MODEL }; + } catch { + console.error('[ai] no se pudo descifrar ai_api_key_enc -- revisa ENCRYPTION_KEY o vuelve a guardar la clave desde Configuracion'); + return null; + } +} + +export async function updateAiConfig(payload, actorId) { + requireEditVersion(payload.editVersion); + const model = AI_MODELS.includes(payload.model) ? payload.model : AI_DEFAULT_MODEL; + const entries = { ai_model: model }; + const apiKey = stripSpaces(payload.apiKey); + if (apiKey) entries.ai_api_key_enc = encrypt(apiKey); + requireUpdated(await repo.setManyIfVersion(entries, 'ai_edit_version', payload.editVersion)); + await logAudit({ userId: actorId, action: 'update', entityType: 'settings:ai', entityId: null, detail: { model, keyChanged: !!apiKey } }); + return getAiConfig(); +} + +// Prueba la clave escrita (o la guardada) con una consulta minima. Propaga el error real. +export async function testAiConfig(payload, actorId) { + const { askStructured } = await import('../../utils/ai.js'); + const typed = stripSpaces(payload?.apiKey); + const stored = typed ? null : await getAiConfigInternal(); + if (!typed && !stored) throw new HttpError(400, 'No hay una clave guardada ni escrita para probar'); + const model = AI_MODELS.includes(payload?.model) ? payload.model : (stored?.model || AI_DEFAULT_MODEL); + const result = await askStructured({ + system: 'Responde solo con la herramienta.', + prompt: 'Responde ok=true.', + toolName: 'prueba', + schema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] }, + maxTokens: 50, + configOverride: { apiKey: typed || stored.apiKey, model }, + }); + await logAudit({ userId: actorId, action: 'test', entityType: 'settings:ai', entityId: null }); + return { ok: !!result.ok, model }; +} diff --git a/backend/src/utils/ai.js b/backend/src/utils/ai.js new file mode 100644 index 0000000..5ff44c3 --- /dev/null +++ b/backend/src/utils/ai.js @@ -0,0 +1,47 @@ +import { getAiConfigInternal } from '../modules/settings/service.js'; +import { HttpError } from './http.js'; + +const API_URL = 'https://api.anthropic.com/v1/messages'; +const TIMEOUT_MS = 60_000; + +// Llama a la API de Mensajes forzando una herramienta, para recibir JSON con el esquema pedido +// (la IA solo sugiere: quien llama valida y el usuario revisa antes de guardar). +export async function askStructured({ system, prompt, toolName, schema, maxTokens = 2000, configOverride }) { + const config = configOverride || await getAiConfigInternal(); + if (!config) throw new HttpError(400, 'La integracion con IA no esta configurada'); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + let res; + try { + res = await fetch(API_URL, { + method: 'POST', + signal: controller.signal, + headers: { 'content-type': 'application/json', 'x-api-key': config.apiKey, 'anthropic-version': '2023-06-01' }, + body: JSON.stringify({ + model: config.model, + max_tokens: maxTokens, + system: `${system}\nResponde SIEMPRE llamando a la herramienta ${toolName}.`, + messages: [{ role: 'user', content: prompt }], + tools: [{ name: toolName, description: 'Devuelve el resultado estructurado.', input_schema: schema }], + // Forzar la herramienta (tool_choice 'tool') da 400 en Sonnet 5.5: se usa 'auto' + instruccion. + tool_choice: { type: 'auto' }, + }), + }); + } catch (err) { + throw new HttpError(502, err.name === 'AbortError' ? 'La IA tardo demasiado en responder' : 'No se pudo conectar con el servicio de IA'); + } finally { + clearTimeout(timer); + } + const body = await res.json().catch(() => null); + if (!res.ok) { + // Mensaje del proveedor sin exponer la clave; 401 = clave invalida, 429 = limite, 400 = saldo/peticion. + const detail = body?.error?.message || `HTTP ${res.status}`; + console.error('[ai] error de la API:', res.status, detail); + throw new HttpError(502, `Servicio de IA: ${detail}`); + } + // Consumo real de cada llamada (para estimar costos: ver precios del modelo en la consola). + console.log(`[ai] ${config.model} tokens entrada=${body?.usage?.input_tokens} salida=${body?.usage?.output_tokens}`); + const tool = body?.content?.find((c) => c.type === 'tool_use'); + if (!tool?.input) throw new HttpError(502, 'La IA no devolvio un resultado valido'); + return tool.input; +} diff --git a/frontend/package.json b/frontend/package.json index 0eba7a6..22c53e9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "3.1.2", + "version": "3.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/PasswordInput.jsx b/frontend/src/components/PasswordInput.jsx index fc499ec..8ea7556 100644 --- a/frontend/src/components/PasswordInput.jsx +++ b/frontend/src/components/PasswordInput.jsx @@ -2,7 +2,9 @@ import { useState } from 'react'; import { Eye, EyeOff } from 'lucide-react'; import { useUi } from '../context/ui-context.jsx'; -export function PasswordInput({ value, onChange, required, autoFocus, placeholder, id }) { +// autoComplete="new-password" en claves de terceros (SMTP, IA): evita que el gestor de contraseñas +// del navegador rellene la clave de login al hacer clic y deje el formulario "con cambios". +export function PasswordInput({ value, onChange, required, autoFocus, placeholder, id, autoComplete }) { const { t } = useUi(); const [visible, setVisible] = useState(false); return ( @@ -16,6 +18,7 @@ export function PasswordInput({ value, onChange, required, autoFocus, placeholde required={required} autoFocus={autoFocus} placeholder={placeholder} + autoComplete={autoComplete} /> + + + + ); +} diff --git a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx index 13a0f54..ef49655 100644 --- a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx +++ b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { Navigate, useSearchParams } from 'react-router-dom'; -import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw, Bug } from 'lucide-react'; +import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw, Bug, Sparkles } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAuth } from '../../context/auth-context.jsx'; import { Expander } from '../../components/Expander.jsx'; @@ -8,6 +8,7 @@ import { Field, FieldRow } from '../../components/Field.jsx'; import { ComboBox } from '../../components/ComboBox.jsx'; import { CatalogEditor } from './CatalogEditor.jsx'; import SmtpSettingsPanel from './SmtpSettingsPanel.jsx'; +import AiSettingsPanel from './AiSettingsPanel.jsx'; import CompanySettingsPanel from './CompanySettingsPanel.jsx'; import UpdaterPanel from './UpdaterPanel.jsx'; import ErrorLogsPanel from './ErrorLogsPanel.jsx'; @@ -119,6 +120,10 @@ export default function ConfiguracionPage() { + + + +
diff --git a/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx b/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx index cde9063..321740f 100644 --- a/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx +++ b/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx @@ -73,7 +73,7 @@ export default function SmtpSettingsPanel() { - setForm((f) => ({ ...f, pass: e.target.value }))} /> + setForm((f) => ({ ...f, pass: e.target.value }))} autoComplete="new-password" /> diff --git a/frontend/src/pages/Proyectos/AiPlanDialog.jsx b/frontend/src/pages/Proyectos/AiPlanDialog.jsx new file mode 100644 index 0000000..7a62e9e --- /dev/null +++ b/frontend/src/pages/Proyectos/AiPlanDialog.jsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { Sparkles, Check } from 'lucide-react'; +import { DetailPopup } from '../../components/DetailPopup.jsx'; +import { Button } from '../../components/Button.jsx'; +import { Field } from '../../components/Field.jsx'; +import { InfoBar } from '../../components/InfoBar.jsx'; +import { useUi } from '../../context/ui-context.jsx'; +import { generateProjectPlan, applyProjectPlan } from '../../services/ai.js'; + +// Plan de actividades sugerido por IA: el usuario describe, revisa la propuesta, desmarca lo que +// no quiere y recien ahi se crean (la IA solo sugiere, nunca guarda sola). +export function AiPlanDialog({ open, projectId, onClose, onCreated }) { + const { t, lang, pushToast } = useUi(); + const [description, setDescription] = useState(''); + const [plan, setPlan] = useState(null); + const [selected, setSelected] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + function close() { + setPlan(null); setSelected([]); setError(''); setDescription(''); + onClose(); + } + + async function generate() { + setBusy(true); setError(''); + try { + const r = await generateProjectPlan(projectId, { description, lang }); + setPlan(r.activities); + setSelected(r.activities.map((_, i) => i)); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + } + + async function apply() { + setBusy(true); setError(''); + try { + const r = await applyProjectPlan(projectId, plan.filter((_, i) => selected.includes(i))); + pushToast({ tone: 'success', message: `${t('ai_plan_created')}: ${r.created}` }); + onCreated(); + close(); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + } + + const toggle = (i) => setSelected((s) => (s.includes(i) ? s.filter((x) => x !== i) : [...s, i])); + + return ( + {} : close} width={720} height={620}> + {error && {error}} + {!plan ? ( + <> +

{t('ai_plan_hint')}

+ +