Skip to main content

Overview

Dashboard Backus displays three floating panels on the map that show live operational statistics. These panels query Supabase views every 15 seconds and update without disrupting the operator’s workflow.

Panel Architecture

// PanelFlotante.tsx - Reusable component
interface Props {
  titulo: string;
  children: ReactNode;
  style?: CSSProperties;  // Absolute positioning
  darkMode?: boolean;
}

const PanelFlotante = ({ titulo, children, style, darkMode = true }: Props) => {
  return (
    <div
      className={`
        absolute z-[25] rounded-xl
        border-t-2 border-sky-400/40
        backdrop-blur-xl
        transition-colors duration-300
        ${darkMode
          ? 'bg-black/75 border border-slate-600/20'
          : 'bg-white/90 border border-slate-200'}
      `}
      style={{
        padding: 'clamp(8px,1vh,14px) clamp(10px,1.2vw,18px)',
        ...style,
      }}
    >
      <div className="text-sky-400 font-bold tracking-widest uppercase opacity-85 mb-2">
        {titulo}
      </div>
      <div>{children}</div>
    </div>
  );
};

Panel Locations

Each panel is positioned using percentage-based coordinates:
// SimuladorMapa.tsx
<PanelFlotante
  titulo="🚨 Unidad Mayor Prioridad"
  darkMode={darkMode}
  style={{ top: '5.69%', left: '70.78%', width: 'clamp(160px,18vw,240px)' }}
>
  {/* Panel 1 content */}
</PanelFlotante>

<PanelFlotante
  titulo="⏱ Tiempo Promedio Patio"
  darkMode={darkMode}
  style={{ top: '31.38%', left: '17.44%', width: 'clamp(140px,15vw,200px)' }}
>
  {/* Panel 2 content */}
</PanelFlotante>

<PanelFlotante
  titulo="🔢 Conteo por Turno"
  darkMode={darkMode}
  style={{ top: '57.93%', left: '17.44%', width: 'clamp(155px,16vw,230px)' }}
>
  {/* Panel 3 content */}
</PanelFlotante>
Panels use clamp() for responsive sizing: clamp(min, preferred, max) ensures readability across screen sizes.

Panel 1: Unidad Mayor Prioridad

Purpose

Shows the truck with the longest wait time in queue or in a bay (excluding finalized trucks).

Data Source

-- vista_unidad_prioridad (Supabase view)
SELECT 
  tracto,
  hora_llegada,
  bahia_actual,
  CURRENT_TIME - hora_llegada AS tiempo_transcurrido
FROM viajes_camiones
WHERE estado != 'Finalizado'
ORDER BY hora_llegada ASC
LIMIT 1;

TypeScript Interface

export interface VwUnidadPrioridad {
  tracto: string;                     // Truck plate
  hora_llegada: string;                // "HH:MM:SS"
  bahia_actual: string | null;         // "Bahía 3" or null if in queue
  tiempo_transcurrido: string;         // "HH:MM:SS+00" (PostgreSQL interval)
}

Fetch Function

export async function fetchUnidadPrioridad(): Promise<VwUnidadPrioridad | null> {
  const { data, error } = await supabase
    .from('vista_unidad_prioridad')
    .select('tracto, hora_llegada, bahia_actual, tiempo_transcurrido')
    .limit(1)
    .maybeSingle();

  if (error) return manejarError('fetchUnidadPrioridad', error);
  return data as VwUnidadPrioridad | null;
}

Display Component

{panelPrioridad ? (
  <div>
    <div className="flex items-center gap-2 mb-1.5">
      <span className="font-extrabold tracking-wider text-red-400"
        style={{ fontSize: 'clamp(0.9rem,1.5vw,1.1rem)' }}>
        {panelPrioridad.tracto}
      </span>
      <span className="rounded-full px-2 py-0.5 font-bold text-black bg-red-400"
        style={{ fontSize: 'clamp(0.6rem,0.9vw,0.72rem)' }}>
        ⏱ {intervalATexto(panelPrioridad.tiempo_transcurrido)}
      </span>
    </div>
    <div className="leading-relajada text-slate-400"
      style={{ fontSize: 'clamp(0.6rem,0.85vw,0.73rem)' }}>
      <div>🕐 Llegada: {panelPrioridad.hora_llegada ?? '—'}</div>
      <div>📍 Bahía: {panelPrioridad.bahia_actual ?? 'En cola'}</div>
    </div>
  </div>
) : (
  <span className="text-slate-500">Sin unidades en cola</span>
)}

Interval Formatting

Converts PostgreSQL interval to human-readable text:
export function intervalATexto(interval: string | null | undefined): string {
  const min = intervalAMinutos(interval);
  if (min <= 0) return '< 1m';
  const h = Math.floor(min / 60);
  const m = Math.floor(min % 60);
  if (h > 0) return `${h}h ${m}m`;
  return `${m}m`;
}

// Examples:
intervalATexto("02:35:00")  // "2h 35m"
intervalATexto("00:45:00")  // "45m"
intervalATexto("00:00:30")  // "< 1m"

Panel 2: Tiempo Promedio Patio

Purpose

Displays the net average time trucks spend in the patio (total time minus incident time) for finalized trucks today.

Data Source

-- vista_promedio_patio_neto (Supabase view)
SELECT 
  AVG(
    (hora_salida - hora_llegada) - COALESCE(
      (SELECT SUM(duracion_calculada) 
       FROM incidencias 
       WHERE id_camion = viajes_camiones.id), 
      INTERVAL '0'
    )
  ) AS promedio_neto_patio
FROM viajes_camiones
WHERE estado = 'Finalizado'
  AND fecha = CURRENT_DATE;

TypeScript Interface

export interface VwPromedioPatioNeto {
  promedio_neto_patio: string | null;  // "HH:MM:SS" or null if no data
}

Fetch Function

export async function fetchPromedioPatioNeto(): Promise<VwPromedioPatioNeto | null> {
  const { data, error } = await supabase
    .from('vista_promedio_patio_neto')
    .select('promedio_neto_patio')
    .maybeSingle();

  if (error) return manejarError('fetchPromedioPatioNeto', error);
  return data as VwPromedioPatioNeto | null;
}

Display Component

{panelPromedio?.promedio_neto_patio ? (
  <>
    <div className="font-extrabold text-sky-400 text-center leading-none"
      style={{ fontSize: 'clamp(1.4rem,2.5vw,2rem)' }}>
      {intervalAMinutos(panelPromedio.promedio_neto_patio).toFixed(1)}
      <span className="font-normal ml-1 text-slate-400"
        style={{ fontSize: 'clamp(0.7rem,1vw,0.9rem)' }}>min</span>
    </div>
    <div className="text-center mt-1 text-slate-500"
      style={{ fontSize: 'clamp(0.52rem,0.7vw,0.65rem)' }}>
      promedio neto del día
    </div>
    <div className="text-green-400 text-center mt-1" style={{ fontSize: '0.58rem' }}>
incidencias descontadas
    </div>
  </>
) : (
  <div className="text-center text-slate-500">Sin finalizados hoy</div>
)}

Minute Conversion

Converts PostgreSQL interval to decimal minutes:
export function intervalAMinutos(interval: string | null | undefined): number {
  if (!interval) return 0;
  if (typeof interval !== 'string') return 0;
  if (!interval.trim()) return 0;

  const clean = interval.replace(/[+-]\d{2}$/, '').trim();
  const negativo = clean.startsWith('-');
  const sinSigno = negativo ? clean.slice(1) : clean;

  let dias = 0;
  let resto = sinSigno;
  const diaMatch = sinSigno.match(/^(\d+)\s+days?\s+/i);
  if (diaMatch) {
    dias = parseInt(diaMatch[1], 10);
    resto = sinSigno.slice(diaMatch[0].length);
  }

  const parts = resto.split(':').map(parseFloat);
  if (parts.length < 2 || parts.some(Number.isNaN)) return 0;

  const [h, m, s = 0] = parts;
  const total = dias * 24 * 60 + h * 60 + m + s / 60;
  return negativo ? -total : total;
}

// Examples:
intervalAMinutos("02:30:00")      // 150.0
intervalAMinutos("00:45:30")      // 45.5
intervalAMinutos("1 day 02:00:00") // 1560.0

Panel 3: Conteo por Turno

Purpose

Shows the count of finalized trucks per shift for today.

Data Source

-- vista_dashboard_turnos (Supabase view)
SELECT
  fecha,
  COUNT(*) FILTER (WHERE turno = 1) AS turno_1,  -- 07:00–15:00
  COUNT(*) FILTER (WHERE turno = 2) AS turno_2,  -- 15:01–23:00
  COUNT(*) FILTER (WHERE turno = 3) AS turno_3   -- 23:01–06:59
FROM viajes_camiones
WHERE estado = 'Finalizado'
GROUP BY fecha
ORDER BY fecha DESC;

TypeScript Interface

export interface VwDashboardTurnos {
  fecha: string;    // "YYYY-MM-DD"
  turno_1: number;  // T1 count
  turno_2: number;  // T2 count
  turno_3: number;  // T3 count
}

Fetch Function

export async function fetchDashboardTurnos(): Promise<VwDashboardTurnos | null> {
  const hoy = new Date().toISOString().slice(0, 10); // "YYYY-MM-DD"
  const { data, error } = await supabase
    .from('vista_dashboard_turnos')
    .select('fecha, turno_1, turno_2, turno_3')
    .eq('fecha', hoy)
    .maybeSingle();

  if (error) return manejarError('fetchDashboardTurnos', error);
  return data as VwDashboardTurnos | null;
}

Display Component

<div className="flex gap-4 justify-center">
  {([
    { key: 'T1', campo: 'turno_1' as const, rango: '07:00–15:00' },
    { key: 'T2', campo: 'turno_2' as const, rango: '15:01–23:00' },
    { key: 'T3', campo: 'turno_3' as const, rango: '23:01–06:59' },
  ] as const).map(({ key, campo, rango }) => (
    <div key={key} className="text-center">
      <div className="font-extrabold text-violet-400 leading-none"
        style={{ fontSize: 'clamp(1.2rem,2vw,1.7rem)' }}>
        {panelTurnos?.[campo] ?? 0}
      </div>
      <div className="mt-0.5 text-slate-500"
        style={{ fontSize: 'clamp(0.52rem,0.7vw,0.68rem)' }}>{key}</div>
      <div className="text-slate-600"
        style={{ fontSize: 'clamp(0.46rem,0.58vw,0.58rem)' }}>{rango}</div>
    </div>
  ))}
</div>

Polling Logic

All panels refresh every 15 seconds while the session is active:
const recargarPaneles = useCallback(async () => {
  const [prioridad, turnos, promedio] = await Promise.all([
    fetchUnidadPrioridad(),
    fetchDashboardTurnos(),
    fetchPromedioPatioNeto(),
  ]);
  if (prioridad != null) setPanelPrioridad(prioridad);
  if (turnos    != null) setPanelTurnos(turnos);
  if (promedio  != null) setPanelPromedio(promedio);
}, []);

useEffect(() => {
  if (!simulacionActiva) return;
  recargarPaneles();
  const poller = setInterval(recargarPaneles, 15_000);
  return () => clearInterval(poller);
}, [simulacionActiva, recargarPaneles]);
Panels only update when simulacionActiva = true. Click ”▶ Iniciar” to start polling.

Loading States

Before session starts, panels show a placeholder:
{!simulacionActiva ? (
  <span className="text-slate-600" style={{ fontSize: '0.75rem' }}>
Iniciar para ver datos
  </span>
) : (
  // Panel content
)}

Empty States

No Priority Truck

{panelPrioridad ? (
  // Display truck data
) : (
  <span className="text-slate-500" style={{ fontSize: '0.8rem' }}>
    Sin unidades en cola
  </span>
)}

No Average Data

{panelPromedio?.promedio_neto_patio ? (
  // Display average
) : (
  <div className="text-center text-slate-500">
    Sin finalizados hoy
  </div>
)}

Responsive Scaling

All text and spacing use clamp() for fluid scaling:
/* Title */
font-size: clamp(0.55rem, 0.68vw, 0.65rem);

/* Primary value */
font-size: clamp(1.4rem, 2.5vw, 2rem);

/* Secondary text */
font-size: clamp(0.52rem, 0.7vw, 0.65rem);

/* Padding */
padding: clamp(8px, 1vh, 14px) clamp(10px, 1.2vw, 18px);

Dark/Light Mode

Panels adapt to theme changes:
const dm = darkMode;

className={`
  ${dm
    ? 'bg-black/75 border border-slate-600/20 text-slate-200'
    : 'bg-white/90 border border-slate-200 text-slate-800'}
`}

Backdrop Blur

Panels use backdrop-blur-xl to remain readable over the satellite map:
backdrop-blur-xl /* Blurs background behind panel */
bg-black/75      /* Semi-transparent background */

Z-Index Layering

z-[25]   // Panels
z-20     // Bay overlays
z-30     // Traffic light legend
z-[999]  // Toast notifications
Panels sit above bays but below toasts, ensuring notifications are always visible.

Interactive Map

Understand how panels integrate with the map layout

Traffic Light System

See how the priority panel uses traffic light data

Build docs developers (and LLMs) love