Skip to main content

Overview

The incident management system allows operators to record and track delays caused by equipment failures, documentation issues, or other operational interruptions. Incident time is automatically excluded from net patio time calculations, providing accurate performance metrics.

Incident Workflow

1

Open Incident

Click “⚠️ Incid.” button on any bay with an active truck
2

Record Start Time

System writes hora_inicio to Supabase incidencias table
3

Active Indicator

Red pulsing dot appears on bay overlay while incident is open
4

Close Incident

Click ”🟢 Cerrar incidencia” when issue is resolved
5

Calculate Duration

Supabase computes duracion_calculada = hora_fin - hora_inicio
6

Update Averages

Net patio time view automatically excludes incident duration

Database Schema

CREATE TABLE incidencias (
  id_incidencia SERIAL PRIMARY KEY,
  id_camion INTEGER NOT NULL REFERENCES viajes_camiones(id),
  hora_inicio TIME NOT NULL,
  hora_fin TIME,
  duracion_calculada INTERVAL GENERATED ALWAYS AS (
    CASE 
      WHEN hora_fin IS NOT NULL 
      THEN hora_fin - hora_inicio 
      ELSE NULL 
    END
  ) STORED
);
Key Fields:
  • id_camion: Foreign key to viajes_camiones.id
  • hora_inicio: Time incident started (set when opening)
  • hora_fin: Time incident ended (null while open)
  • duracion_calculada: Computed column (read-only)

Opening an Incident

UI Button

The incident button appears on occupied bays:
// BahiaOverlay.tsx
<button
  onClick={e => { 
    e.stopPropagation(); 
    setShowIncidencia(true); 
  }}
  title="Registrar incidencia"
  style={{
    flex: 1,
    padding: '3px 0',
    borderRadius: 5,
    border: 'none',
    background: 'rgba(245,158,11,0.18)',
    color: '#fbbf24',
    fontSize: '0.65rem',
    fontWeight: 700,
    cursor: 'pointer',
  }}
>
  ⚠️ Incid.
</button>
// ModalIncidencia.tsx
interface Props {
  show: boolean;
  camion: Camion;
  bahiaNombre: string;
  onClose: () => void;
  onNotify: (msg: string, tipo?: 'success' | 'error' | 'info') => void;
  onIncidenciaRegistrada: (camionId: string) => void;
}

Insert Query

export async function abrirIncidencia(id_camion: number): Promise<IncidenciaRow | null> {
  const { data, error } = await supabase
    .from('incidencias')
    .insert({
      id_camion,
      hora_inicio: horaActualTime(),
      hora_fin: null,
    })
    .select()
    .maybeSingle();

  if (error) return manejarError('abrirIncidencia', error);
  return data as IncidenciaRow | null;
}

function horaActualTime(): string {
  return new Date().toLocaleTimeString('es-PE', { hour12: false }); // "HH:MM:SS"
}

Closing an Incident

Update Query

export async function cerrarIncidencia(id_camion: number): Promise<IncidenciaRow | null> {
  const { data, error } = await supabase
    .from('incidencias')
    .update({ hora_fin: horaActualTime() })
    .eq('id_camion', id_camion)
    .is('hora_fin', null)  // Only update open incidents
    .select()
    .maybeSingle();

  if (error) return manejarError('cerrarIncidencia', error);
  if (!data) {
    console.warn('[supabaseService] No open incident found for id_camion =', id_camion);
    return null;
  }
  return data as IncidenciaRow;
}
The .is('hora_fin', null) filter ensures only open incidents are closed. If no open incident exists, the update returns null.

Incident Counter

The modal displays a visual counter showing up to 3 incidents:
const MAX_INCIDENCIAS = 3;

<div style={{ display: 'flex', gap: 6, marginBottom: hayAbierta ? 8 : 0 }}>
  {Array.from({ length: MAX_INCIDENCIAS }).map((_, i) => (
    <div key={`slot-${i}`} style={{
      width: 32,
      height: 32,
      borderRadius: 7,
      background: i < conteoLocal
        ? (i < conteoLocal - (hayAbierta ? 1 : 0) ? '#f59e0b' : '#ef4444')
        : 'rgba(148,163,184,0.1)',
      border: `1px solid ${i < conteoLocal ? '#f59e0b' : 'rgba(148,163,184,0.2)'}`,
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      fontSize: '0.75rem',
      fontWeight: 700,
      color: i < conteoLocal ? '#000' : '#475569',
      boxShadow: i === conteoLocal - 1 && hayAbierta ? '0 0 8px #ef4444' : 'none',
      animation: i === conteoLocal - 1 && hayAbierta ? 'pulse 1s infinite' : 'none',
    }}>
      {i < conteoLocal ? (i < conteoLocal - (hayAbierta ? 1 : 0) ? '✓' : '●') : i + 1}
    </div>
  ))}
</div>
Visual States:
  • Empty slot: Gray box with number (1, 2, 3)
  • Closed incident: Orange box with checkmark (✓)
  • Open incident: Red pulsing box with dot (●)

Maximum Incident Limit

Trucks are limited to 3 incidents per session:
const handleRegistrar = async () => {
  if (limiteSuperado) {
    onNotify(
      '🚨 Límite de 3 incidencias alcanzado. Contactar a los desarrolladores.',
      'error'
    );
    return;
  }

  if (hayAbierta) {
    onNotify(
      '⚠️ Ya hay una incidencia abierta. Ciérrala antes de abrir una nueva.',
      'error'
    );
    return;
  }

  setLoading(true);
  const resultado = await abrirIncidencia(camion.id_db);
  setLoading(false);

  if (resultado) {
    onIncidenciaRegistrada(camion.id);
    onNotify(
      `⚠️ Incidencia ${conteoLocal + 1}/${MAX_INCIDENCIAS} abierta — ${camion.placa}`,
      'success'
    );
    onClose();
  } else {
    onNotify(
      '❌ Error al abrir la incidencia en Supabase. Verifica la conexión.',
      'error'
    );
  }
};
When a truck reaches 3 incidents, the modal blocks further registrations and displays a warning message.

Active Incident Indicator

Bays with open incidents show a pulsing red dot:
// BahiaOverlay.tsx - Polling every 8 seconds
useEffect(() => {
  if (!camion?.id_db) {
    startTransition(() => setIncidenciaActiva(false));
    return;
  }

  const idCamion = Number(camion.id_db);
  fetchIncidenciaAbierta(idCamion).then(setIncidenciaActiva);

  const poller = setInterval(() => {
    fetchIncidenciaAbierta(idCamion).then(setIncidenciaActiva);
  }, 8_000);

  return () => clearInterval(poller);
}, [camion?.id_db]);

// Display indicator
{incidenciaActiva && (
  <span style={{
    fontSize: '0.65rem',
    animation: 'pulse 1s infinite',
    lineHeight: 1,
  }} title="Incidencia activa sin cerrar">
    🔴
  </span>
)}

Query Function

export async function fetchIncidenciaAbierta(id_camion: number): Promise<boolean> {
  const { count, error } = await supabase
    .from('incidencias')
    .select('*', { count: 'exact', head: true })
    .eq('id_camion', id_camion)
    .is('hora_fin', null);

  if (error) {
    manejarError('fetchIncidenciaAbierta', error);
    return false;
  }
  return (count ?? 0) > 0;
}

Average Incident Duration

The modal shows the average duration of closed incidents for the truck:
export async function fetchPromedioIncidencias(id_camion: number): Promise<string | null> {
  const { data, error } = await supabase
    .from('incidencias')
    .select('duracion_calculada')
    .eq('id_camion', id_camion)
    .not('hora_fin', 'is', null)     // Only closed incidents
    .not('duracion_calculada', 'is', null);

  if (error) {
    manejarError('fetchPromedioIncidencias', error);
    return null;
  }
  if (!data?.length) return null;

  const minutos = (data as { duracion_calculada: string }[])
    .map(r => intervalAMinutos(r.duracion_calculada))
    .filter(m => m > 0);

  if (!minutos.length) return null;

  const promedioMin = minutos.reduce((a, b) => a + b, 0) / minutos.length;
  const h = Math.floor(promedioMin / 60);
  const m = Math.floor(promedioMin % 60);
  const s = Math.floor((promedioMin % 1) * 60);
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
Display:
{promedioTexto && (
  <Row label="⏱ Prom. incidencias" value={promedioTexto} />
)}

Net Patio Time Calculation

The vista_promedio_patio_neto view automatically subtracts incident time:
CREATE VIEW vista_promedio_patio_neto AS
SELECT 
  AVG(
    (hora_salida - hora_llegada) - COALESCE(
      (SELECT SUM(duracion_calculada) 
       FROM incidencias 
       WHERE id_camion = viajes_camiones.id
         AND hora_fin IS NOT NULL),  -- Only closed incidents
      INTERVAL '0'
    )
  ) AS promedio_neto_patio
FROM viajes_camiones
WHERE estado = 'Finalizado'
  AND fecha = CURRENT_DATE;
Formula:
Net Time = (hora_salida - hora_llegada) - SUM(incident durations)
Only closed incidents (with hora_fin set) are deducted. Open incidents are excluded from the calculation.

Polling Strategy

The modal uses HTTP polling instead of Supabase Realtime to avoid additional costs:
useEffect(() => {
  if (!show || !camion.id_db) return;

  const refrescar = () => {
    contarIncidencias(camion.id_db).then(setConteoLocal);
    fetchIncidenciaAbierta(camion.id_db).then(setHayAbierta);
    fetchPromedioIncidencias(camion.id_db).then(t => 
      setPromedioTexto(t ? intervalATexto(t) : null)
    );
  };

  refrescar();  // Immediate load
  const poller = setInterval(refrescar, 5_000);  // Refresh every 5s
  return () => clearInterval(poller);
}, [show, camion.id_db]);
Why polling?
  • Lower cost: No Realtime subscription fees
  • Simpler architecture: No WebSocket management
  • Sufficient latency: 5-8 second delays are acceptable for incident tracking

Button States

Open Button

<button
  style={{ opacity: (loading || hayAbierta) ? 0.45 : 1 }}
  onClick={handleRegistrar}
  disabled={loading || hayAbierta}
  title={hayAbierta ? 'Cierra la incidencia activa antes de abrir una nueva' : ''}
>
  {loading ? '⏳' : '🔴 Abrir incidencia'}
</button>
Disabled when:
  • Another incident is already open
  • API request is in progress
  • Limit of 3 incidents reached

Close Button

<button
  style={{ opacity: (loading || !hayAbierta) ? 0.45 : 1 }}
  onClick={handleCerrar}
  disabled={loading || !hayAbierta}
  title={!hayAbierta ? 'No hay ninguna incidencia abierta' : ''}
>
  {loading ? '⏳' : '🟢 Cerrar incidencia'}
</button>
Disabled when:
  • No open incident exists
  • API request is in progress

Count Synchronization

The modal re-syncs the counter whenever the parent updates:
useEffect(() => {
  startTransition(() => {
    setConteoLocal(camion.incidencias ?? 0);
  });
}, [camion.incidencias]);
startTransition marks the update as non-urgent, preventing UI blocking during re-renders.

Toast Notifications

// Success
onNotify(`⚠️ Incidencia ${conteoLocal + 1}/3 abierta — ${camion.placa}`, 'success');

// Error
onNotify('❌ Error al abrir la incidencia en Supabase. Verifica la conexión.', 'error');

// Info
onNotify('ℹ️ No hay ninguna incidencia abierta para cerrar.', 'info');

Dashboard Panels

See how net patio time appears in the average panel

Role-Based Access

Learn which roles can register incidents

Build docs developers (and LLMs) love