From 9d50bc856400184ebf6ced8a7317f39369f77774 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Fri, 23 Oct 2026 18:19:24 -0500 Subject: [PATCH] feat: version 4.2.0 -- Informe de estado del proyecto con IA Co-Authored-By: Claude Opus 5.5 --- API.md | 1 + CHANGELOG.md | 8 ++ ESTADO.md | 5 +- VERSION | 2 +- backend/package.json | 2 +- backend/src/modules/ai/controller.js | 4 + backend/src/modules/ai/routes.js | 3 +- backend/src/modules/ai/service.js | 75 +++++++++++++ frontend/package.json | 2 +- frontend/src/i18n/messages.js | 42 +++++++ .../pages/Proyectos/ProjectReportDialog.jsx | 103 ++++++++++++++++++ .../pages/Proyectos/ProyectoDetallePage.jsx | 14 ++- frontend/src/services/ai.js | 1 + 13 files changed, 255 insertions(+), 7 deletions(-) create mode 100644 frontend/src/pages/Proyectos/ProjectReportDialog.jsx diff --git a/API.md b/API.md index d0ef28c..fb39158 100644 --- a/API.md +++ b/API.md @@ -184,6 +184,7 @@ el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`. - `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. +- `POST /api/ai/projects/:projectId/report` `{ lang? }` → `{ project, generatedAt, summary, health, achievements, delays: [{ item, reason }], risks, nextSteps }`. Requiere lectura del proyecto; 30/hora. - `GET /api/settings/ai/usage?period=month|all` → `{ period, users: [{ userId, userName, calls, inputTokens, outputTokens, costUsd, lastUsed }], total }` — solo superadmin. - `GET/PUT /api/settings/ai` `{ apiKey?, model, editVersion }` y `POST /api/settings/ai/test` — solo superadmin; la clave nunca se devuelve. diff --git a/CHANGELOG.md b/CHANGELOG.md index b7557f2..1f74d03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog - project.linkedcorp.net +## [4.2.0] - 2026-10-23 +### Added +- "Informe con IA" en el detalle de cada proyecto (con la IA configurada y permiso de lectura): + la IA lee actividades, avance, fechas, responsables, tareas hechas/vencidas y los últimos + comentarios, y redacta un informe de estado para jefatura: estado general, resumen, avances, + atrasos con motivo, riesgos y próximos pasos. Solo usa los datos del sistema; botones Copiar, + Descargar .txt y Volver a generar. Solo lectura (no modifica nada). Se registra en el consumo. + ## [4.1.0] - 2026-10-23 ### Added - Configuración → IA → "Consumo por usuario": consultas, tokens de entrada/salida, costo estimado diff --git a/ESTADO.md b/ESTADO.md index 02cd0cf..90c3cd6 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). -- IA (v4.0.0 hecho: config, "Plan con IA" y asistente "Nuevo con IA"). Siguiente posible: resumen - semanal del Panel; responsables por actividad entre los miembros del proyecto. +- IA (hecho hasta v4.2.0: config, "Plan con IA", "Nuevo con IA" con duplicados, consumo por usuario, + "Informe con IA"). Ideas siguientes: resumen semanal del Panel, detector de riesgos, resumir + comentarios en tareas, dividir tareas grandes, responsables por actividad entre miembros. - 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 ee74734..6aba2b2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.1.0 +4.2.0 diff --git a/backend/package.json b/backend/package.json index bf365ad..93effbb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.1.0", + "version": "4.2.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/ai/controller.js b/backend/src/modules/ai/controller.js index 7a1c380..c45e72e 100644 --- a/backend/src/modules/ai/controller.js +++ b/backend/src/modules/ai/controller.js @@ -18,3 +18,7 @@ export const postProposeProject = asyncHandler(async (req, res) => { export const postCreateProject = asyncHandler(async (req, res) => { ok(res, await service.createNewProject(req.body || {}, req.currentUser), 201); }); + +export const postProjectReport = asyncHandler(async (req, res) => { + ok(res, await service.generateProjectReport(Number(req.params.projectId), req.body?.lang, req.currentUser)); +}); diff --git a/backend/src/modules/ai/routes.js b/backend/src/modules/ai/routes.js index ae72852..4eabc63 100644 --- a/backend/src/modules/ai/routes.js +++ b/backend/src/modules/ai/routes.js @@ -2,7 +2,7 @@ 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, postProposeProject, postCreateProject } from './controller.js'; +import { getStatus, postProjectPlan, postApplyProjectPlan, postProposeProject, postCreateProject, postProjectReport } from './controller.js'; const router = Router(); @@ -14,6 +14,7 @@ router.get('/status', getStatus); 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/report', requireModule('projects'), aiLimiter, postProjectReport); 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 index 7efdb33..6942f6b 100644 --- a/backend/src/modules/ai/service.js +++ b/backend/src/modules/ai/service.js @@ -285,3 +285,78 @@ async function findCandidates(text, user) { .sort((a, b) => b.score - a.score) .slice(0, MAX_CANDIDATES); } + +// --- Informe de estado del proyecto --- +// Lee el estado real (actividades, tareas, fechas, avance, comentarios recientes) y la IA redacta +// un informe para jefatura. Solo lectura: no modifica nada. Basta permiso de lectura del proyecto. + +const REPORT_SCHEMA = { + type: 'object', + properties: { + summary: { type: 'string' }, + health: { type: 'string', enum: ['en_curso', 'en_riesgo', 'atrasado', 'completado', 'sin_iniciar'] }, + achievements: { type: 'array', items: { type: 'string' } }, + delays: { type: 'array', items: { type: 'object', properties: { item: { type: 'string' }, reason: { type: 'string' } }, required: ['item', 'reason'] } }, + risks: { type: 'array', items: { type: 'string' } }, + nextSteps: { type: 'array', items: { type: 'string' } }, + }, + required: ['summary', 'health', 'achievements', 'delays', 'risks', 'nextSteps'], +}; + +export async function generateProjectReport(projectId, lang, user) { + const project = await getProject(projectId, user); + const today = isoDate(new Date()); + const [activities] = await pool.query( + `SELECT a.id, a.name, a.start_date, a.due_date, a.actual_end_date, a.progress_pct, s.name AS status_name, s.is_closed, + u.name AS responsible, + (SELECT COUNT(*) FROM tasks t WHERE t.activity_id = a.id AND t.deleted_at IS NULL) AS tasks_total, + (SELECT COUNT(*) FROM tasks t WHERE t.activity_id = a.id AND t.deleted_at IS NULL AND t.is_done = 1) AS tasks_done, + (SELECT GROUP_CONCAT(t.name SEPARATOR '; ') FROM tasks t WHERE t.activity_id = a.id AND t.deleted_at IS NULL + AND t.is_done = 0 AND t.due_date < CURDATE()) AS overdue_tasks + FROM activities a JOIN activity_statuses s ON s.id = a.status_id LEFT JOIN users u ON u.id = a.responsible_user_id + WHERE a.project_id = ? AND a.deleted_at IS NULL ORDER BY a.sort_order, a.id`, + [projectId] + ); + const [comments] = await pool.query( + `SELECT c.message, c.created_at, u.name AS author FROM comments c LEFT JOIN users u ON u.id = c.user_id + WHERE (c.entity_type = 'project' AND c.entity_id = ?) + OR (c.entity_type = 'activity' AND c.entity_id IN (SELECT id FROM activities WHERE project_id = ? AND deleted_at IS NULL)) + ORDER BY c.created_at DESC LIMIT 15`, + [projectId, projectId] + ); + const fmt = (d) => (d ? String(d).slice(0, 10) : '-'); + const lines = activities.map((a) => [ + `- ${clip(a.name, 150)} | estado: ${a.status_name} | avance: ${a.progress_pct || 0}% | ${fmt(a.start_date)} -> ${fmt(a.due_date)}`, + a.actual_end_date ? ` | terminada: ${fmt(a.actual_end_date)}` : '', + ` | responsable: ${a.responsible || 'sin asignar'} | tareas: ${a.tasks_done}/${a.tasks_total}`, + a.overdue_tasks ? ` | tareas vencidas: ${clip(a.overdue_tasks, 300)}` : '', + ].join('')); + const result = await askStructured({ + system: `Eres un jefe de proyectos de TI que redacta informes de estado claros y honestos para la jefatura. Usa solo los datos dados, no inventes hechos ni cifras. Se concreto: menciona actividades y fechas. Responde en ${languageFor(lang)}.`, + prompt: [ + `Fecha de hoy: ${today}`, + `Proyecto: ${project.code} ${project.name} | estado: ${project.status_name || '-'} | avance: ${project.progress_pct ?? 0}%`, + `Fechas: inicio ${fmt(project.start_date)}, fin estimado ${fmt(project.estimated_end_date)}`, + project.objective ? `Objetivo: ${clip(project.objective, 500)}` : '', + activities.length ? `Actividades:\n${lines.join('\n')}` : 'El proyecto aun no tiene actividades.', + comments.length ? `Comentarios recientes:\n${comments.map((c) => `- ${fmt(c.created_at)} ${c.author || ''}: ${clip(c.message, 300)}`).join('\n')}` : '', + 'Redacta: summary (3-5 oraciones con la situacion general), health, achievements (lo completado o avanzado), delays (lo atrasado o vencido con su motivo probable segun los datos), risks y nextSteps (acciones concretas para la proxima semana). Listas vacias si no aplica.', + ].filter(Boolean).join('\n'), + toolName: 'informe_de_estado', + schema: REPORT_SCHEMA, + maxTokens: 8000, + usage: { userId: user.id, action: 'project_report' }, + }); + await logAudit({ userId: user.id, action: 'ai_report', entityType: 'project', entityId: projectId }); + const list = (v) => (Array.isArray(v) ? v.map((x) => clip(x, 500)).filter(Boolean).slice(0, 15) : []); + return { + project: { code: project.code, name: project.name }, + generatedAt: new Date().toISOString(), + summary: clip(result.summary, 2000), + health: result.health, + achievements: list(result.achievements), + delays: (Array.isArray(result.delays) ? result.delays : []).slice(0, 15).map((d) => ({ item: clip(d?.item, 300), reason: clip(d?.reason, 500) })).filter((d) => d.item), + risks: list(result.risks), + nextSteps: list(result.nextSteps), + }; +} diff --git a/frontend/package.json b/frontend/package.json index 5ab48c3..255c061 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.1.0", + "version": "4.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 51ec2df..e345cc0 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -63,6 +63,27 @@ export const messages = { ai_usage_total: 'Total', ai_usage_hint: 'costo estimado; el monto exacto está en la consola de Anthropic.', ai_usage_empty: 'Todavía no hay consumo registrado.', + ai_report_button: 'Informe con IA', + ai_report_title: 'Informe de estado', + ai_report_busy: 'La IA está redactando el informe...', + ai_report_date: 'Generado', + ai_report_health: 'Estado general', + ai_report_summary: 'Resumen', + ai_report_achievements: 'Avances', + ai_report_delays: 'Atrasos', + ai_report_risks: 'Riesgos', + ai_report_next: 'Próximos pasos', + ai_report_disclaimer: 'Redactado por IA a partir de los datos del sistema; revísalo antes de enviarlo.', + ai_report_copy: 'Copiar', + ai_report_download: 'Descargar .txt', + ai_report_regenerate: 'Volver a generar', + ai_report_copied: 'Informe copiado al portapapeles', + ai_report_copy_failed: 'No se pudo copiar; usa Descargar.', + ai_health_en_curso: 'En curso', + ai_health_en_riesgo: 'En riesgo', + ai_health_atrasado: 'Atrasado', + ai_health_completado: 'Completado', + ai_health_sin_iniciar: 'Sin iniciar', nav_projects: 'Proyectos', nav_organization: 'Organizacion', nav_config: 'Configuracion', @@ -643,6 +664,27 @@ export const messages = { ai_usage_total: 'Total', ai_usage_hint: 'estimated cost; the exact amount is in the Anthropic console.', ai_usage_empty: 'No usage recorded yet.', + ai_report_button: 'AI report', + ai_report_title: 'Status report', + ai_report_busy: 'AI is writing the report...', + ai_report_date: 'Generated', + ai_report_health: 'Overall status', + ai_report_summary: 'Summary', + ai_report_achievements: 'Progress', + ai_report_delays: 'Delays', + ai_report_risks: 'Risks', + ai_report_next: 'Next steps', + ai_report_disclaimer: 'Written by AI from system data; review it before sending.', + ai_report_copy: 'Copy', + ai_report_download: 'Download .txt', + ai_report_regenerate: 'Regenerate', + ai_report_copied: 'Report copied to clipboard', + ai_report_copy_failed: 'Could not copy; use Download.', + ai_health_en_curso: 'On track', + ai_health_en_riesgo: 'At risk', + ai_health_atrasado: 'Delayed', + ai_health_completado: 'Completed', + ai_health_sin_iniciar: 'Not started', nav_projects: 'Projects', nav_organization: 'Organization', nav_config: 'Settings', diff --git a/frontend/src/pages/Proyectos/ProjectReportDialog.jsx b/frontend/src/pages/Proyectos/ProjectReportDialog.jsx new file mode 100644 index 0000000..d76ba66 --- /dev/null +++ b/frontend/src/pages/Proyectos/ProjectReportDialog.jsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react'; +import { Copy, Download, RefreshCw } from 'lucide-react'; +import { DetailPopup } from '../../components/DetailPopup.jsx'; +import { Button } from '../../components/Button.jsx'; +import { Badge } from '../../components/Badge.jsx'; +import { InfoBar } from '../../components/InfoBar.jsx'; +import { BusyPanel } from '../../components/BusyPanel.jsx'; +import { useUi } from '../../context/ui-context.jsx'; +import { generateProjectReport } from '../../services/ai.js'; + +const HEALTH_COLOR = { en_curso: 'success', en_riesgo: 'warning', atrasado: 'danger', completado: 'success', sin_iniciar: 'muted' }; + +// Texto plano del informe, para copiar a un correo o descargar. +function reportToText(r, t) { + const section = (title, items) => (items.length ? `\n${title}\n${items.map((x) => `- ${x}`).join('\n')}\n` : ''); + return [ + `${t('ai_report_title')}: ${r.project.code} — ${r.project.name}`, + `${t('ai_report_date')}: ${new Date(r.generatedAt).toLocaleString()}`, + `${t('ai_report_health')}: ${t(`ai_health_${r.health}`)}`, + `\n${t('ai_report_summary')}\n${r.summary}\n`, + section(t('ai_report_achievements'), r.achievements), + section(t('ai_report_delays'), r.delays.map((d) => `${d.item}: ${d.reason}`)), + section(t('ai_report_risks'), r.risks), + section(t('ai_report_next'), r.nextSteps), + ].join('\n').replace(/\n{3,}/g, '\n\n'); +} + +// Informe de estado redactado por la IA a partir de los datos reales del proyecto (solo lectura). +export function ProjectReportDialog({ open, projectId, onClose }) { + const { t, lang, pushToast } = useUi(); + const [report, setReport] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + async function generate() { + setBusy(true); setError(''); + try { + setReport(await generateProjectReport(projectId, { lang })); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + } + + // Se genera al abrir (no al montar la pagina): cada informe es una consulta a la IA. + useEffect(() => { + if (open && !report && !busy) generate(); + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + async function copy() { + try { + await navigator.clipboard.writeText(reportToText(report, t)); + pushToast({ tone: 'success', message: t('ai_report_copied') }); + } catch { + pushToast({ tone: 'error', message: t('ai_report_copy_failed') }); + } + } + + function download() { + const blob = new Blob([reportToText(report, t)], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `informe-${report.project.code}-${report.generatedAt.slice(0, 10)}.txt`; + a.click(); + URL.revokeObjectURL(url); + } + + const List = ({ title, items }) => (items.length ? ( + <> +

{title}

+ + + ) : null); + + return ( + {} : onClose} width={760} height={680}> + {error && {error}} + {busy && } + {report && !busy && ( + <> +
+ {t(`ai_health_${report.health}`)} + {t('ai_report_date')}: {new Date(report.generatedAt).toLocaleString()} +
+

{t('ai_report_summary')}

+

{report.summary}

+ + `${d.item}: ${d.reason}`)} /> + + +

{t('ai_report_disclaimer')}

+
+ + + +
+ + )} +
+ ); +} diff --git a/frontend/src/pages/Proyectos/ProyectoDetallePage.jsx b/frontend/src/pages/Proyectos/ProyectoDetallePage.jsx index b40c1c8..c36335a 100644 --- a/frontend/src/pages/Proyectos/ProyectoDetallePage.jsx +++ b/frontend/src/pages/Proyectos/ProyectoDetallePage.jsx @@ -1,5 +1,8 @@ +import { useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { ArrowLeft } from 'lucide-react'; +import { ArrowLeft, FileText } from 'lucide-react'; +import { ProjectReportDialog } from './ProjectReportDialog.jsx'; +import { getAiStatus } from '../../services/ai.js'; import { useUi } from '../../context/ui-context.jsx'; import { useAsync } from '../../hooks/useAsync.js'; import { useUserDrafts } from '../../hooks/useDraftForm.js'; @@ -44,6 +47,9 @@ export default function ProyectoDetallePage() { const deepLinkActivityTab = searchParams.get('activityTab') || undefined; const { data: project, reload } = useAsync(() => getProject(projectId), [projectId]); + // Informe con IA: solo si el superadmin configuro la clave (el backend valida lectura del proyecto). + const { data: aiStatus } = useAsync(() => getAiStatus().catch(() => ({ enabled: false })), []); + const [reportOpen, setReportOpen] = useState(false); if (!project) return
{t('common_loading')}
; @@ -55,7 +61,13 @@ export default function ProyectoDetallePage() {

{project.code} — {project.name}

+ {aiStatus?.enabled && ( +
+ +
+ )}
+ setReportOpen(false)} /> api.post(`/ai/projects/$ 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); +export const generateProjectReport = (projectId, data) => api.post(`/ai/projects/${projectId}/report`, data);