Ir al contenido

Control Horario

Ruta: /employee/my-portal/time-control Componente: TimeControl.tsx Permiso requerido: my_portal

Vista mensual/semanal del registro de fichajes con estadísticas en tiempo real, opción de añadir fichajes manuales y sistema de solicitud de correcciones a RRHH.


Métricas dinámicas (mes o semana):

  • Total horas — Suma de horas trabajadas
  • Media diaria — Promedio por día trabajado
  • Pausas — Total de tiempo en descansos

Columnas:

  • Checkbox — Selección múltiple
  • Día — Fecha con día de la semana
  • Ubicación — Oficina, Remoto, Con ubicación, Sin definir
  • Horas trabajadas — Total del día
  • Tiempo de descanso — Suma de pausas
  • Estado — Badge (Pendiente, Aprobado, Rechazado, En curso)

Acciones por fila:

  • Click en fila → Abre detalle del día
  • Doble click → Abre edición (si permitido)

Toggle de navegación:

  • Botones Mes / Semana
  • Navegación con ← →
  • Botón “Hoy” para volver al día actual
  • Scroll automático al día actual

Botón ”+ Añadir fichaje”:

  • Selección de fecha (una o múltiples con checkbox)
  • Sheet con editor de sesiones de trabajo
  • Validación de horarios
  • Solo días sin fichajes

Restricciones:

  • No se puede añadir si ya hay fichajes ese día
  • No se puede añadir si hay fichaje en curso
  • Fichajes manuales quedan en estado pending

Botón “Ver solicitudes”:

  • Lista de solicitudes de corrección enviadas
  • Badge con contador de pendientes
  • Historial completo con estados

Sheet de corrección desde detalle del día:

  • Muestra fichajes actuales
  • Campo de motivo de corrección
  • Descripción de cambios solicitados
  • Envío a RRHH para aprobación

Procesamiento de fichajes diarios
const dailyData = useMemo(() => {
if (!monthlyEntries) return {};
const grouped: Record<string, {
entries: TimeEntry[],
totalHours: number,
totalBreaks: number,
location: string,
status: 'pendiente' | 'aprobado' | 'rechazado' | 'en_curso',
canEdit: boolean
}> = {};
monthlyEntries.forEach(entry => {
const dateKey = format(new Date(entry.event_timestamp), 'yyyy-MM-dd');
if (!grouped[dateKey]) {
grouped[dateKey] = {
entries: [],
totalHours: 0,
totalBreaks: 0,
location: getLocationFromEntry(entry),
status: 'pendiente',
canEdit: true
};
}
grouped[dateKey].entries.push(entry);
});
// Calcular horas usando parseTimeEntriesToSessions
Object.keys(grouped).forEach(dateKey => {
const sessions = parseTimeEntriesToSessions(grouped[dateKey].entries);
const totalHours = sessions.reduce((sum, session) => {
return sum + session.ranges
.filter(r => r.type === 'work')
.reduce((acc, r) => acc + (r.duration || 0), 0);
}, 0);
grouped[dateKey].totalHours = totalHours;
});
return grouped;
}, [monthlyEntries]);

Función parseTimeEntriesToSessions:

  • Agrupa eventos en sesiones lógicas
  • Calcula rangos de trabajo y pausa
  • Valida secuencias de eventos
  • Detecta jornadas en curso
Detección de ubicación
const getLocationFromEntry = (entry: TimeEntry): string => {
// 1. Intentar extraer de notes [LOCATION:...]
if (entry.notes) {
const locationMatch = entry.notes.match(/\[LOCATION:(.+?)\]/);
if (locationMatch) return locationMatch[1];
}
// 2. Check geolocation manual marker
if (entry.geolocation?.manual) {
return entry.geolocation.location || 'Con ubicación';
}
// 3. Si modality es remote
if (entry.modality === 'remote') return 'Remoto';
// 4. Si on_site con geolocalización real
if (entry.modality === 'on_site' && entry.geolocation?.lat) {
return 'Con ubicación';
}
// 5. Si on_site manual sin geo
if (entry.modality === 'on_site' && entry.entry_source === 'manual') {
return 'Oficina';
}
// Default
return 'Sin definir';
};

Tipos de ubicación:

  • Oficina — Fichaje manual on-site sin GPS
  • Remoto — modality = ‘remote’
  • Con ubicación — Tiene coordenadas GPS
  • Sin definir — Fichajes automáticos sin datos
Estados de aprobación
type ApprovalStatus = 'pending' | 'approved' | 'rejected';

Lógica de estado:

const approvalStatus = entry.approval_status;
if (approvalStatus === 'approved') {
dayData.status = 'aprobado';
dayData.canEdit = false; // No editable
} else if (approvalStatus === 'rejected') {
dayData.status = 'rechazado';
dayData.canEdit = true; // Puede corregir
} else {
dayData.status = 'pendiente';
dayData.canEdit = true; // Puede editar
}

Fichaje en curso:

const hasActiveClockIn = entries.some(entry => {
if (entry.event_type === 'clock_in') {
const hasClockOut = entries.some(e =>
e.event_type === 'clock_out' &&
new Date(e.event_timestamp) > new Date(entry.event_timestamp)
);
return !hasClockOut;
}
return false;
});
if (hasActiveClockIn) {
dayData.status = 'en_curso';
dayData.canEdit = false;
}
Añadir fichajes manuales
const canAddTimeEntry = useMemo(() => {
if (selectedDays.size === 0) return true;
for (const dateKey of selectedDays) {
const dayData = dailyData[dateKey];
if (dayData && dayData.entries.length > 0) {
return false; // Ya tiene fichajes
}
}
return true;
}, [selectedDays, dailyData]);
const convertSlotsToEvents = async (date: string, sessions: any[], empId: string, busId: string) => {
const entries: TimeEntry[] = [];
for (const session of sessions) {
const workSlots = session.slots.filter((s: any) => s.type === 'work');
const breakSlots = session.slots.filter((s: any) => s.type === 'break');
// 1. clock_in al inicio de primer slot de trabajo
const firstSlot = workSlots[0];
const clockInTime = new Date(`${date}T${firstSlot.startTime}:00`);
entries.push({
event_type: 'clock_in',
event_timestamp: clockInTime.toISOString(),
modality: session.location === 'Remoto' ? 'remote' : 'on_site',
entry_source: 'manual',
approval_status: 'pending',
notes: `[LOCATION:${session.location}]`,
...
});
// 2. break_start y break_end para cada pausa
for (const brk of breakSlots) {
// Agregar break_start y break_end
}
// 3. clock_out al final del último slot de trabajo
const lastSlot = workSlots[workSlots.length - 1];
const clockOutTime = new Date(`${date}T${lastSlot.endTime}:00`);
entries.push({
event_type: 'clock_out',
event_timestamp: clockOutTime.toISOString(),
...
});
}
// Validar secuencia antes de insertar
if (!validateEventSequence(entries)) {
throw new Error('Secuencia inválida de eventos');
}
return entries;
};
Sistema de correcciones
const { createRequest, isCreating, requests } = useEmployeeCorrectionRequests(
employeeId,
currentBusinessId
);

Estructura de solicitud:

interface CorrectionRequest {
id: string;
employee_id: string;
request_date: string;
reason: string;
requested_changes: string;
status: 'pending' | 'approved' | 'rejected';
created_at: string;
reviewed_at: string | null;
}

Crear solicitud:

createRequest({
requestDate: '2025-01-15',
reason: 'Olvidé fichar la salida',
requestedChanges: 'Solicito añadir clock_out a las 18:00',
});

Tabla: time_correction_requests


Ver detalle del día:

  • Timeline visual de fichajes
  • Total de horas y pausas
  • Sesiones de trabajo separadas
  • Botón “Editar” (si permitido)
  • Botón “Solicitar corrección”

Editar fichajes:

  • Editor de sesiones con horarios
  • Añadir/eliminar sesiones
  • Modificar pausas
  • Validación en tiempo real
  • Guardar → Reemplaza fichajes existentes

Solicitar corrección:

  • Vista read-only de fichajes actuales
  • Campo de motivo (requerido)
  • Campo de cambios solicitados
  • Envío a cola de RRHH

Añadir fichaje manual:

  • Lista de fechas seleccionadas
  • Editor de sesiones por fecha
  • Selector de ubicación
  • Validación de horarios
  • Múltiples sesiones por día

Card “Mi Jornada”:

  • Muestra horario contractual del empleado
  • Entrada y salida esperadas
  • Días laborables con badges
  • Fuente: work_schedules table
<Card>
<CardContent>
<div className="flex items-center gap-4">
<div>Entrada: {schedule.start_time}</div>
<div>Salida: {schedule.end_time}</div>
</div>
<div className="flex gap-1">
{schedules.map(s => (
<Badge>{dayNames[s.day_of_week]}</Badge>
))}
</div>
</CardContent>
</Card>