Ir al contenido

Inicio - Mi Portal

Ruta: /employee/my-portal Componente: Home.tsx Permiso requerido: my_portal

Dashboard principal del empleado con sistema de fichaje tipo Factorial. Incluye cronómetro en tiempo real, estados visuales y gestión de jornadas abiertas.


  • Fecha actual en español con formato completo
  • Timer grande con horas, minutos y segundos en tiempo real
  • Estado visual con punto de color y badge:
    • 🔴 Trabajando — Verde con animación pulse
    • 🟡 En pausa — Amarillo
    • 🟢 Jornada completada — Verde
    • Desconectado — Gris

Estados del empleado:

EstadoBotón principalAcción secundaria
Desconectado▶️ Fichar entrada (verde)-
Trabajando⏸️ Iniciar pausa (amarillo)⏹️ Fichar salida
En pausa▶️ Reanudar trabajo (verde)-
Jornada completada▶️ Nueva jornada (verde)-

Confirmación de salida:

  • Diálogo de confirmación antes de fichar salida
  • Previene cierres accidentales
  • Muestra hora actual
  • Hora de entrada: Se muestra una vez fichado
  • Jornadas abiertas: Detección automática de fichajes sin cerrar
    • Si hay jornada abierta del día anterior → Diálogo de regularización
    • Opción de cerrar automáticamente o corregir manualmente

Implementación del cronómetro
const {
currentStatus, // Estado actual: 'working', 'on_break', 'not_clocked', 'day_complete'
todayEntries, // Array de fichajes del día
isLoadingToday, // Loading state
clockMutation, // Mutación para fichar
employeeId // ID del empleado
} = useTimeEntries(user?.id, currentBusinessId);

Tipos de evento:

type EventType = 'clock_in' | 'clock_out' | 'break_start' | 'break_end';
useEffect(() => {
if (status !== "working") {
setLiveTime(totalHoursToday);
return;
}
const baselineHours = totalHoursToday;
const baselineMs = Date.now();
const tick = () => {
const elapsedMs = Date.now() - baselineMs;
setLiveTime(baselineHours + elapsedMs / (1000 * 60 * 60));
};
// Sincronización con segundos completos
const msToNextSecond = 1000 - (Date.now() % 1000);
const timeoutId = setTimeout(() => {
tick();
const intervalId = setInterval(tick, 1000);
}, msToNextSecond);
return () => {
clearTimeout(timeoutId);
clearInterval(intervalId);
};
}, [status, totalHoursToday]);

Características:

  • Actualización cada segundo
  • Sincronizado con el reloj del sistema
  • Reseteo automático en cambios de estado
  • Sin drift acumulativo
const getTimerParts = (hours: number) => {
const totalSeconds = Math.floor(hours * 3600);
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
return {
hours: h.toString().padStart(2, '0'),
minutes: m.toString().padStart(2, '0'),
seconds: s.toString().padStart(2, '0'),
};
};

Tipografía monoespaciada:

<div style={{
fontVariantNumeric: 'tabular-nums slashed-zero',
fontFeatureSettings: '"tnum", "zero"'
}}>
{timerParts.hours}h {timerParts.minutes}m {timerParts.seconds}s
</div>
Gestión de jornadas abiertas

Detecta jornadas que no fueron cerradas correctamente (sin clock_out).

const { data: openWorkday, refetch: refetchOpenWorkday } = useOpenWorkday(
employeeId,
currentBusinessId
);

Estructura de jornada abierta:

interface OpenWorkday {
date: string; // Fecha de la jornada abierta
entries: TimeEntry[]; // Fichajes del día
last_clock_in_time: string; // Última entrada sin salida
}

Si se detecta jornada abierta al intentar clock_in:

if (eventType === 'clock_in' && openWorkday) {
setShowOpenWorkdayDialog(true);
return;
}

Componente: OpenWorkdayDialog

  • Muestra fecha y hora de la jornada abierta
  • Opciones:
    1. Cerrar automáticamente — Añade clock_out a la jornada anterior
    2. Corregir manualmente — Redirige a Control Horario para editar
  • Callback onResolved invalida queries para refrescar datos
Tabla de base de datos
ColumnaTipoDescripción
iduuidID único del fichaje
employee_iduuidReferencia al empleado
business_iduuidReferencia al negocio
event_timestamptimestamptzMomento exacto del fichaje
event_typetext’clock_in’, ‘clock_out’, ‘break_start’, ‘break_end’
modalitytext’on_site’, ‘remote’
geolocationjsonbCoordenadas GPS (opcional)
entry_sourcetext’automatic’, ‘manual’, ‘admin’
approval_statustext’pending’, ‘approved’, ‘rejected’
is_supersededbooleantrue si fue reemplazado por corrección
notestextNotas adicionales

Índices:

  • idx_time_entries_employee_business en (employee_id, business_id)
  • idx_time_entries_timestamp en (event_timestamp)
  • idx_time_entries_superseded en (is_superseded) WHERE is_superseded = false

Políticas RLS:

  • Empleados solo ven sus propios fichajes
  • Owners ven todos los fichajes de su negocio
  • HR managers pueden ver según permisos
Estados y transiciones
type Status = 'not_clocked' | 'working' | 'on_break' | 'day_complete';

Transiciones permitidas:

not_clocked ──clock_in──> working
┌─────────────┴─────────────┐
│ │
break_start clock_out
│ │
v v
on_break ──break_end──> working day_complete

Validaciones:

  • No se puede clock_out si hay pausa activa
  • break_end solo si existe break_start previo
  • Nueva jornada solo después de day_complete
const getStatusConfig = () => {
switch (status) {
case 'working':
return {
label: 'Trabajando',
dotColor: 'bg-red-500', // Rojo en vivo
badgeClass: 'bg-success/20 text-success'
};
case 'on_break':
return {
label: 'En pausa',
dotColor: 'bg-amber-500',
badgeClass: 'bg-amber-100 text-amber-700'
};
case 'day_complete':
return {
label: 'Jornada completada',
dotColor: 'bg-green-500',
badgeClass: 'bg-green-100 text-green-700'
};
default:
return {
label: 'Desconectado',
dotColor: 'bg-gray-400',
badgeClass: 'bg-gray-100 text-gray-600'
};
}
};

<Button
size="icon"
className="h-20 w-20 rounded-full bg-[hsl(var(--brand-green))] hover:bg-[hsl(var(--brand-green-dark))] text-[hsl(var(--brand-black))] shadow-lg"
onClick={() => handleClock('clock_in')}
>
<Play className="h-8 w-8" />
</Button>

Colores Factux:

  • Verde: hsl(68 100% 50%)
  • Verde oscuro: hsl(68 100% 45%)
  • Negro: hsl(0 0% 10%)

Desktop:

  • Timer centrado con espacio vertical generoso
  • Botones grandes (80x80px)
  • Badge y estado prominentes

Mobile:

  • Layout vertical compacto
  • Botones medianos (64x64px)
  • Touch targets de 44px mínimo