Skip to content

Commit 66c502e

Browse files
authored
Merge pull request #21 from Llorente29/feature/formacion-c2
Feature/formacion c2
2 parents 714ab42 + 94442a9 commit 66c502e

10 files changed

Lines changed: 2054 additions & 3 deletions

File tree

src/components/personal/FormacionesTab.tsx

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {
1414
Infinity,
1515
X,
1616
} from 'lucide-react'
17-
import { Card, Button, Input, Select, Label, Textarea } from '../ui'
17+
import { Card, Button, Badge, Input, Select, Label, Textarea } from '../ui'
18+
import { useApp } from '../../context/AppContext'
1819
import type { Employee } from '../../types'
1920
import type { Formation, FormationType } from '../../types/personal'
2021
import { FORMATION_CATALOG } from '../../types/personal'
@@ -25,6 +26,8 @@ import {
2526
deleteFormation,
2627
getFormationStatus,
2728
} from '../../services/formationsService'
29+
import * as trainingComplianceService from '../../services/trainingComplianceService'
30+
import type { TrainingCellState } from '../../services/trainingComplianceService'
2831

2932
interface Props {
3033
employee: Employee
@@ -89,9 +92,13 @@ export default function FormacionesTab({ employee }: Props) {
8992
</div>
9093
</Card>
9194

95+
{/* Formación interna (C1/C2): cursos impartidos por Folvy con test y firma.
96+
NO toca employee_formations ni la lista de abajo — es una sección aparte. */}
97+
<InternalTrainingSection employee={employee} />
98+
9299
<div className="flex items-center justify-between">
93100
<p className="text-xs text-text-secondary">
94-
{formations.length} formación{formations.length !== 1 ? 'es' : ''}
101+
{formations.length} formación{formations.length !== 1 ? 'es' : ''} externa{formations.length !== 1 ? 's' : ''}
95102
</p>
96103
<Button size="sm" onClick={() => { setEditing(null); setShowModal(true) }}>
97104
+ Añadir formación
@@ -197,6 +204,81 @@ export default function FormacionesTab({ employee }: Props) {
197204
)
198205
}
199206

207+
/* =====================================================
208+
FORMACIÓN INTERNA (C1/C2) — cursos impartidos por Folvy
209+
===================================================== */
210+
211+
const INTERNAL_STATE_LABEL: Record<TrainingCellState, string> = {
212+
vigente: 'Vigente', caducado: 'Caducado', pendiente: 'Pendiente', en_curso: 'En curso (sin firmar)', no_aplica: 'No aplica',
213+
}
214+
const INTERNAL_STATE_COLOR: Record<TrainingCellState, string> = {
215+
vigente: 'green', caducado: 'red', pendiente: 'gray', en_curso: 'yellow', no_aplica: 'gray',
216+
}
217+
218+
function InternalTrainingSection({ employee }: { employee: Employee }) {
219+
const { activeAccountId } = useApp()
220+
const [courses, setCourses] = useState<{ code: string; title: string; state: TrainingCellState; scorePct: number | null; expiresAt: string | null }[]>([])
221+
const [loading, setLoading] = useState(true)
222+
const [error, setError] = useState(false)
223+
224+
useEffect(() => {
225+
if (!activeAccountId) { setLoading(false); return }
226+
let cancel = false
227+
setLoading(true)
228+
setError(false)
229+
Promise.all([
230+
trainingComplianceService.getTrainingComplianceMatrix(activeAccountId, employee.locationId, false),
231+
trainingComplianceService.getTrainingCourseSummary(activeAccountId),
232+
])
233+
.then(([matrix, summary]) => {
234+
if (cancel) return
235+
const row = matrix.find((r) => r.employeeId === employee.id)
236+
const titleByCode = new Map(summary.map((c) => [c.courseCode, c.courseTitle]))
237+
const list = Object.entries(row?.courses ?? {})
238+
.filter(([, cell]) => cell.state !== 'no_aplica')
239+
.map(([code, cell]) => ({
240+
code,
241+
title: titleByCode.get(code) ?? code,
242+
state: cell.state,
243+
scorePct: cell.scorePct,
244+
expiresAt: cell.expiresAt,
245+
}))
246+
setCourses(list)
247+
})
248+
.catch(() => { if (!cancel) setError(true) })
249+
.finally(() => { if (!cancel) setLoading(false) })
250+
return () => { cancel = true }
251+
}, [activeAccountId, employee.id, employee.locationId])
252+
253+
return (
254+
<Card className="p-3">
255+
<p className="text-sm font-semibold text-text-primary inline-flex items-center gap-1.5 mb-2">
256+
<GraduationCap size={14} /> Formación interna (Folvy)
257+
</p>
258+
{loading ? (
259+
<p className="text-xs text-text-secondary">Cargando…</p>
260+
) : error ? (
261+
<p className="text-xs text-danger">No se pudo cargar la formación interna.</p>
262+
) : courses.length === 0 ? (
263+
<p className="text-xs text-text-secondary">Sin cursos internos asignados todavía.</p>
264+
) : (
265+
<div className="space-y-1.5">
266+
{courses.map((c) => (
267+
<div key={c.code} className="flex items-center justify-between gap-2 text-xs">
268+
<span className="text-text-primary">{c.title}</span>
269+
<div className="flex items-center gap-2 shrink-0">
270+
{c.scorePct != null && <span className="text-text-secondary">{c.scorePct}%</span>}
271+
{c.expiresAt && <span className="text-text-secondary">hasta {new Date(c.expiresAt).toLocaleDateString('es-ES')}</span>}
272+
<Badge color={INTERNAL_STATE_COLOR[c.state]}>{INTERNAL_STATE_LABEL[c.state]}</Badge>
273+
</div>
274+
</div>
275+
))}
276+
</div>
277+
)}
278+
</Card>
279+
)
280+
}
281+
200282
/* =====================================================
201283
MODAL DE EDICIÓN / ALTA
202284
===================================================== */

src/modules/appcc/module.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import {
1818
BarChart3, Leaf, AlertTriangle, ClipboardCheck, FolderOpen, FileText, Settings, ShieldAlert,
19+
GraduationCap,
1920
} from 'lucide-react'
2021
import type { ModuleDefinition } from '@/shell/types'
2122

@@ -27,6 +28,7 @@ import OnboardingPage from '@/modules/appcc/pages/OnboardingPage'
2728
import ReportsPage from '@/modules/appcc/pages/ReportsPage'
2829
import TemplateEditorPage from '@/modules/appcc/pages/TemplateEditorPage'
2930
import AllergensCompliancePage from '@/modules/appcc/pages/AllergensCompliancePage'
31+
import TrainingCompliancePage from '@/modules/appcc/pages/TrainingCompliancePage'
3032
import AuditsPage from '@/modules/appcc/audits/AuditsPage'
3133
import AuditExecutionPage from '@/modules/appcc/audits/AuditExecutionPage'
3234
import AuditTemplateEditorPage from '@/modules/appcc/audits/AuditTemplateEditorPage'
@@ -57,6 +59,7 @@ export const appccModule: ModuleDefinition = {
5759
{ path: 'plantillas', element: <TemplateEditorPage /> },
5860
{ path: 'onboarding', element: <OnboardingPage /> },
5961
{ path: 'alergenos', element: <AllergensCompliancePage /> },
62+
{ path: 'formacion', element: <TrainingCompliancePage /> },
6063
],
6164

6265
// Navegación interna del módulo (ModuleSidebar).
@@ -70,6 +73,7 @@ export const appccModule: ModuleDefinition = {
7073
{ id: 'appcc_audits', label: 'Auditorías', icon: ClipboardCheck, path: 'auditorias', requiredRole: 'admin' },
7174
{ id: 'appcc_audit_templates', label: 'Plantillas Auditoría', icon: FolderOpen, path: 'auditorias/plantillas', requiredRole: 'admin' },
7275
{ id: 'appcc_allergens', label: 'Alérgenos', icon: ShieldAlert, path: 'alergenos', requiredRole: 'admin' },
76+
{ id: 'appcc_training', label: 'Formación', icon: GraduationCap, path: 'formacion', requiredRole: 'admin' },
7377
{ id: 'appcc_reports', label: 'Informes', icon: FileText, path: 'informes', requiredRole: 'admin' },
7478
{ id: 'appcc_templates', label: 'Plantillas', icon: FolderOpen, path: 'plantillas', requiredRole: 'admin' },
7579
{ id: 'appcc_onboarding', label: 'Configurar', icon: Settings, path: 'onboarding', requiredRole: 'admin' },

src/modules/appcc/pages/AppccDashboardPage.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ import {
2121
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
2222
PieChart, Pie, Cell, BarChart, Bar,
2323
} from 'recharts'
24+
import { useNavigate } from 'react-router-dom'
2425
import { useApp } from '@/context/AppContext'
2526
import { useLocationScope } from '@/modules/multitenancy/hooks/useLocationScope'
27+
import { useActiveAccount } from '@/modules/multitenancy/hooks/useActiveAccount'
2628
import * as analyticsService from '@/modules/appcc/services/analyticsService'
29+
import * as trainingComplianceService from '@/services/trainingComplianceService'
2730
import type {
2831
DateRange,
2932
KpiSummary,
@@ -176,6 +179,9 @@ export default function AppccDashboardPage() {
176179
<div className="bg-danger-bg text-danger rounded-md p-3 text-sm">{error}</div>
177180
)}
178181

182+
{/* ============ PRERREQUISITO: FORMACIÓN (Capa 2) ============ */}
183+
<TrainingPrerequisiteCard />
184+
179185
{/* ============ KPIs ============ */}
180186
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
181187
<KpiBox
@@ -634,3 +640,61 @@ function Heatmap({ cells }: { cells: HeatmapCell[] }) {
634640
</div>
635641
)
636642
}
643+
644+
// ============ PRERREQUISITO: FORMACIÓN (Capa 2) ============
645+
// Tarjeta autocontenida (su propio fetch, no comparte estado con el resto
646+
// del dashboard) — así no se toca ninguna lógica existente del dashboard
647+
// para añadir esto. Semáforo = mismo cálculo que el KPI de portada del
648+
// informe (computeMandatoryCompliancePct), para que nunca puedan divergir.
649+
function TrainingPrerequisiteCard() {
650+
const { activeAccountId } = useActiveAccount()
651+
const navigate = useNavigate()
652+
const [kpi, setKpi] = useState<{ pct: number; vigente: number; applicable: number } | null>(null)
653+
const [loading, setLoading] = useState(true)
654+
const [failed, setFailed] = useState(false)
655+
656+
useEffect(() => {
657+
if (!activeAccountId) { setLoading(false); return }
658+
let cancelled = false
659+
trainingComplianceService.getTrainingComplianceMatrix(activeAccountId, null, true)
660+
.then((rows) => {
661+
if (cancelled) return
662+
setKpi(trainingComplianceService.computeMandatoryCompliancePct(rows))
663+
setFailed(false)
664+
})
665+
.catch(() => { if (!cancelled) setFailed(true) })
666+
.finally(() => { if (!cancelled) setLoading(false) })
667+
return () => { cancelled = true }
668+
}, [activeAccountId])
669+
670+
const tone = failed || kpi == null
671+
? { dot: 'bg-text-tertiary', text: 'text-text-secondary' }
672+
: kpi.pct === 100
673+
? { dot: 'bg-success', text: 'text-success' }
674+
: kpi.pct >= 80
675+
? { dot: 'bg-warning', text: 'text-warning' }
676+
: { dot: 'bg-danger', text: 'text-danger' }
677+
678+
return (
679+
<button
680+
type="button"
681+
onClick={() => navigate('/appcc/formacion')}
682+
className="w-full flex items-center justify-between gap-3 bg-card rounded-lg border border-border-default p-3 sm:p-4 text-left hover:bg-page transition-colors"
683+
>
684+
<div className="flex items-center gap-3 min-w-0">
685+
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${tone.dot}`} />
686+
<div className="min-w-0">
687+
<p className="text-sm font-semibold text-text-primary">Prerrequisito: Formación del personal</p>
688+
<p className="text-xs text-text-secondary truncate">
689+
{loading ? 'Cargando…' : failed || kpi == null
690+
? 'No se pudo calcular — abre el informe para más detalle.'
691+
: `${kpi.vigente} de ${kpi.applicable} con la formación obligatoria vigente`}
692+
</p>
693+
</div>
694+
</div>
695+
<span className={`text-lg font-bold shrink-0 ${tone.text}`}>
696+
{loading || failed || kpi == null ? '—' : `${kpi.pct}%`}
697+
</span>
698+
</button>
699+
)
700+
}

0 commit comments

Comments
 (0)