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
+42
View File
@@ -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',
@@ -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 { 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 <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 }}>
<h2 className="page-title mt-0">{project.code} — {project.name}</h2>
<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>
<ProjectReportDialog open={reportOpen} projectId={projectId} onClose={() => setReportOpen(false)} />
<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 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);