feat: version 4.2.0 -- Informe de estado del proyecto con IA

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-23 18:19:24 -05:00
parent 120e54283e
commit 9d50bc8564
13 changed files with 255 additions and 7 deletions
+1
View File
@@ -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/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/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/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 /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. - `GET/PUT /api/settings/ai` `{ apiKey?, model, editVersion }` y `POST /api/settings/ai/test` — solo superadmin; la clave nunca se devuelve.
+8
View File
@@ -1,5 +1,13 @@
# Changelog - project.linkedcorp.net # 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 ## [4.1.0] - 2026-10-23
### Added ### Added
- Configuración → IA → "Consumo por usuario": consultas, tokens de entrada/salida, costo estimado - Configuración → IA → "Consumo por usuario": consultas, tokens de entrada/salida, costo estimado
+3 -2
View File
@@ -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. - 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 (v4.0.0 hecho: config, "Plan con IA" y asistente "Nuevo con IA"). Siguiente posible: resumen - IA (hecho hasta v4.2.0: config, "Plan con IA", "Nuevo con IA" con duplicados, consumo por usuario,
semanal del Panel; responsables por actividad entre los miembros del proyecto. "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). - 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 -1
View File
@@ -1 +1 @@
4.1.0 4.2.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.1.0", "version": "4.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+4
View File
@@ -18,3 +18,7 @@ export const postProposeProject = asyncHandler(async (req, res) => {
export const postCreateProject = asyncHandler(async (req, res) => { export const postCreateProject = asyncHandler(async (req, res) => {
ok(res, await service.createNewProject(req.body || {}, req.currentUser), 201); 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));
});
+2 -1
View File
@@ -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, postProposeProject, postCreateProject } from './controller.js'; import { getStatus, postProjectPlan, postApplyProjectPlan, postProposeProject, postCreateProject, postProjectReport } from './controller.js';
const router = Router(); const router = Router();
@@ -14,6 +14,7 @@ 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/propose', requireModule('projects'), aiLimiter, postProposeProject);
router.post('/new-project', requireModule('projects'), postCreateProject); 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); router.post('/projects/:projectId/plan/apply', requireModule('projects'), postApplyProjectPlan);
export default router; export default router;
+75
View File
@@ -285,3 +285,78 @@ async function findCandidates(text, user) {
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
.slice(0, MAX_CANDIDATES); .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),
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "4.1.0", "version": "4.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+42
View File
@@ -63,6 +63,27 @@ export const messages = {
ai_usage_total: 'Total', ai_usage_total: 'Total',
ai_usage_hint: 'costo estimado; el monto exacto está en la consola de Anthropic.', ai_usage_hint: 'costo estimado; el monto exacto está en la consola de Anthropic.',
ai_usage_empty: 'Todavía no hay consumo registrado.', 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_projects: 'Proyectos',
nav_organization: 'Organizacion', nav_organization: 'Organizacion',
nav_config: 'Configuracion', nav_config: 'Configuracion',
@@ -643,6 +664,27 @@ export const messages = {
ai_usage_total: 'Total', ai_usage_total: 'Total',
ai_usage_hint: 'estimated cost; the exact amount is in the Anthropic console.', ai_usage_hint: 'estimated cost; the exact amount is in the Anthropic console.',
ai_usage_empty: 'No usage recorded yet.', 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_projects: 'Projects',
nav_organization: 'Organization', nav_organization: 'Organization',
nav_config: 'Settings', nav_config: 'Settings',
@@ -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 ? (
<>
<h4 style={{ margin: '14px 0 4px' }}>{title}</h4>
<ul style={{ margin: 0, paddingLeft: 18, fontSize: '.88rem' }}>{items.map((x, i) => <li key={i}>{x}</li>)}</ul>
</>
) : null);
return (
<DetailPopup open={open} title={t('ai_report_title')} onClose={busy ? () => {} : onClose} width={760} height={680}>
{error && <InfoBar tone="error">{error}</InfoBar>}
{busy && <BusyPanel message={t('ai_report_busy')} hint={t('ai_busy_hint')} />}
{report && !busy && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
<Badge color={HEALTH_COLOR[report.health] || 'muted'}>{t(`ai_health_${report.health}`)}</Badge>
<span className="text-muted" style={{ fontSize: '.8rem' }}>{t('ai_report_date')}: {new Date(report.generatedAt).toLocaleString()}</span>
</div>
<h4 style={{ margin: '8px 0 4px' }}>{t('ai_report_summary')}</h4>
<p style={{ margin: 0, fontSize: '.9rem' }}>{report.summary}</p>
<List title={t('ai_report_achievements')} items={report.achievements} />
<List title={t('ai_report_delays')} items={report.delays.map((d) => `${d.item}: ${d.reason}`)} />
<List title={t('ai_report_risks')} items={report.risks} />
<List title={t('ai_report_next')} items={report.nextSteps} />
<p className="text-muted" style={{ fontSize: '.75rem', marginTop: 14 }}>{t('ai_report_disclaimer')}</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="accent" icon={Copy} onClick={copy}>{t('ai_report_copy')}</Button>
<Button variant="standard" icon={Download} onClick={download}>{t('ai_report_download')}</Button>
<Button variant="standard" icon={RefreshCw} onClick={generate}>{t('ai_report_regenerate')}</Button>
</div>
</>
)}
</DetailPopup>
);
}
@@ -1,5 +1,8 @@
import { useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; 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 { useUi } from '../../context/ui-context.jsx';
import { useAsync } from '../../hooks/useAsync.js'; import { useAsync } from '../../hooks/useAsync.js';
import { useUserDrafts } from '../../hooks/useDraftForm.js'; import { useUserDrafts } from '../../hooks/useDraftForm.js';
@@ -44,6 +47,9 @@ export default function ProyectoDetallePage() {
const deepLinkActivityTab = searchParams.get('activityTab') || undefined; const deepLinkActivityTab = searchParams.get('activityTab') || undefined;
const { data: project, reload } = useAsync(() => getProject(projectId), [projectId]); 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 <div className="text-muted">{t('common_loading')}</div>; if (!project) return <div className="text-muted">{t('common_loading')}</div>;
@@ -55,7 +61,13 @@ export default function ProyectoDetallePage() {
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<h2 className="page-title mt-0">{project.code} — {project.name}</h2> <h2 className="page-title mt-0">{project.code} — {project.name}</h2>
<StatusBadge name={project.status_name} color={project.status_color} /> <StatusBadge name={project.status_name} color={project.status_color} />
{aiStatus?.enabled && (
<div style={{ marginLeft: 'auto' }}>
<Button variant="standard" icon={FileText} onClick={() => setReportOpen(true)}>{t('ai_report_button')}</Button>
</div> </div>
)}
</div>
<ProjectReportDialog open={reportOpen} projectId={projectId} onClose={() => setReportOpen(false)} />
<Tabs <Tabs
tabs={[ tabs={[
+1
View File
@@ -5,3 +5,4 @@ export const generateProjectPlan = (projectId, data) => api.post(`/ai/projects/$
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 proposeNewProject = (data) => api.post('/ai/new-project/propose', data);
export const createNewProject = (data) => api.post('/ai/new-project', data); export const createNewProject = (data) => api.post('/ai/new-project', data);
export const generateProjectReport = (projectId, data) => api.post(`/ai/projects/${projectId}/report`, data);