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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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={[
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user