import { useState, useEffect, useMemo, useRef } from "react"; import { Droplets, Users, Car, UserRound, Wallet, Plus, X, Check, Clock, TrendingUp, Search, Trash2, Pencil, ChevronRight, Sparkles, Banknote, CreditCard, ArrowRightLeft, Receipt, Scale, Package, PackagePlus, PackageMinus, AlertTriangle, Settings, ArrowLeft, CalendarDays, CalendarCheck, CalendarX, PlayCircle, LogOut, ShieldCheck, Activity, Gift, Minus, PartyPopper, Download, StickyNote, FileText, Share2, Printer, Star, UserX, History, Repeat, Bell, BarChart3, PiggyBank, ArrowDownCircle, ArrowUpCircle } from "lucide-react"; const STORAGE_KEY = "autolavado-data"; const SESION_KEY = "autolavado-sesion"; const SUCURSAL_KEY = "autolavado-sucursal"; const DEFAULT_TIPOS_SERVICIO = [ { nombre: "Lavado Básico", precio: 80 }, { nombre: "Lavado Premium", precio: 130 }, { nombre: "Lavado y Encerado", precio: 180 }, { nombre: "Aspirado", precio: 50 }, { nombre: "Lavado de Motor", precio: 100 }, { nombre: "Detallado Completo", precio: 350 }, ]; const DEFAULT_ADICIONALES = [ { nombre: "Cera", precio: 50 }, { nombre: "Lavado de Motor", precio: 100 }, { nombre: "Aromatizante", precio: 30 }, { nombre: "Brillo de llantas", precio: 40 }, ]; const METODOS_PAGO = [ { id: "efectivo", label: "Efectivo", icon: Banknote }, { id: "tarjeta", label: "Tarjeta", icon: CreditCard }, { id: "transferencia", label: "Transferencia", icon: ArrowRightLeft }, ]; const CATEGORIAS_GASTO = [ "Insumos y jabones", "Sueldos", "Renta", "Agua y luz", "Mantenimiento de equipo", "Otro", ]; const UNIDADES = ["piezas", "litros", "galones", "kg", "paquetes", "botes"]; const TIPOS_VEHICULO = ["Sedán", "Hatchback", "SUV", "Pickup", "Van/Camioneta", "Motocicleta"]; const todayStr = () => new Date().toISOString().split("T")[0]; // determina si un registro pertenece a la sucursal activa (los registros viejos sin sucursal se asignan a la primera) const uid = () => Math.random().toString(36).slice(2, 10); const pertenece = (item, sucursalId, sucursales) => { const principal = sucursales && sucursales[0] ? sucursales[0].id : "suc-principal"; return (item.sucursalId || principal) === sucursalId; }; const DEFAULT_FIDELIDAD = [ { id: "prog-5-50", nombre: "5 lavados", metaSellos: 5, premio: "50% de descuento" }, { id: "prog-10-gratis", nombre: "10 lavados", metaSellos: 10, premio: "Un lavado gratis" }, ]; // progreso de un cliente en un programa, con compatibilidad con la versión anterior (un solo contador "sellos") const getSellos = (cliente, programa, index) => { if (cliente.progreso && typeof cliente.progreso[programa.id] === "number") return cliente.progreso[programa.id]; if (index === 0 && typeof cliente.sellos === "number") return cliente.sellos; return 0; }; const upsertCliente = (clientes, nombre, placas, tipoVehiculo) => { const nombreLimpio = nombre.trim(); const idx = clientes.findIndex((c) => c.nombre.trim().toLowerCase() === nombreLimpio.toLowerCase()); if (idx === -1) { return [...clientes, { id: uid(), nombre: nombreLimpio, telefono: "", vehiculo: tipoVehiculo || "", placas: (placas || "").trim(), sellos: 0 }]; } const copia = [...clientes]; const existente = copia[idx]; copia[idx] = { ...existente, placas: (placas || "").trim() || existente.placas, vehiculo: existente.vehiculo || tipoVehiculo || "", sellos: Number(existente.sellos) || 0, }; return copia; }; const money = (n) => "$" + Number(n || 0).toLocaleString("es-MX", { maximumFractionDigits: 0 }); const fecha = (d) => new Date(d + "T00:00:00").toLocaleDateString("es-MX", { weekday: "short", day: "numeric", month: "short", }); const duracionTexto = (ms) => { const min = Math.round(ms / 60000); if (min < 1) return "menos de 1 min"; if (min < 60) return `${min} min`; const h = Math.floor(min / 60); const m = min % 60; return `${h}h ${m}min`; }; // convierte un timestamp (ms) al formato que espera , en hora local const tsToInputLocal = (ts) => { const d = new Date(ts); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; }; const inputLocalToTs = (v) => new Date(v).getTime(); const tiempoRelativo = (ts) => { const diffMin = Math.round((Date.now() - ts) / 60000); if (diffMin < 1) return "justo ahora"; if (diffMin < 60) return `hace ${diffMin} min`; const h = Math.round(diffMin / 60); if (h < 24) return `hace ${h} h`; const d = Math.round(h / 24); return `hace ${d} d`; }; const tiempoHasta = (ts) => { const diffMin = Math.round((ts - Date.now()) / 60000); if (diffMin <= 0) return "ahora"; if (diffMin < 60) return `en ${diffMin} min`; const h = Math.floor(diffMin / 60); const m = diffMin % 60; return m > 0 ? `en ${h}h ${m}min` : `en ${h}h`; }; const diasEntre = (fechaA, fechaB) => Math.round((new Date(fechaB) - new Date(fechaA)) / 86400000); const csvEscape = (val) => { const s = String(val ?? ""); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; const arrayToCsv = (rows, headers) => { const head = headers.map((h) => csvEscape(h.label)).join(","); const body = rows .map((r) => headers.map((h) => csvEscape(typeof h.value === "function" ? h.value(r) : r[h.value])).join(",")) .join("\n"); return head + "\n" + body; }; const descargarArchivo = (nombre, contenido, tipo) => { const blob = new Blob([contenido], { type: tipo }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = nombre; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; const DEFAULT_SUCURSALES = [{ id: "suc-principal", nombre: "Sucursal Principal" }]; const DEFAULT_CARTERAS = [{ id: "cartera-insumos", nombre: "Insumos y productos", saldo: 0 }]; const emptyData = { nombreNegocio: "Mi Autolavado", metaDiaria: 1500, clientes: [], servicios: [], empleados: [], gastos: [], inventario: [], movimientosInventario: [], tiposServicio: DEFAULT_TIPOS_SERVICIO, serviciosAdicionales: DEFAULT_ADICIONALES, citas: [], actividad: [], adminPin: "", fidelidad: DEFAULT_FIDELIDAD, sucursales: DEFAULT_SUCURSALES, carteras: DEFAULT_CARTERAS, movimientosCartera: [], membresias: [], }; export default function App() { const [data, setData] = useState(null); const [tab, setTab] = useState("dashboard"); const [ready, setReady] = useState(false); const [err, setErr] = useState(""); const [showConfig, setShowConfig] = useState(false); const [sesion, setSesion] = useState(null); const [sesionReady, setSesionReady] = useState(false); const [sucursalAdmin, setSucursalAdmin] = useState(null); useEffect(() => { (async () => { try { const res = await window.storage.get(STORAGE_KEY, true); const merged = res ? { ...emptyData, ...JSON.parse(res.value) } : emptyData; if (!Array.isArray(merged.fidelidad)) merged.fidelidad = DEFAULT_FIDELIDAD; if (!Array.isArray(merged.sucursales) || merged.sucursales.length === 0) merged.sucursales = DEFAULT_SUCURSALES; setData(merged); } catch { setData(emptyData); } finally { setReady(true); } })(); (async () => { try { const res = await window.storage.get(SESION_KEY, false); setSesion(res ? JSON.parse(res.value) : null); } catch { setSesion(null); } finally { setSesionReady(true); } })(); (async () => { try { const res = await window.storage.get(SUCURSAL_KEY, false); setSucursalAdmin(res ? res.value : null); } catch { setSucursalAdmin(null); } })(); }, []); const save = async (next) => { setData(next); try { const ok = await window.storage.set(STORAGE_KEY, JSON.stringify(next), true); if (!ok) setErr("No se pudo guardar. Intenta de nuevo."); else setErr(""); } catch { setErr("No se pudo guardar. Revisa tu conexión."); } }; const elegirSesion = async (s) => { setSesion(s); setTab(s.rol === "lavador" ? "citas" : "dashboard"); try { await window.storage.set(SESION_KEY, JSON.stringify(s), false); } catch {} }; const cerrarSesion = async () => { setSesion(null); try { await window.storage.delete(SESION_KEY, false); } catch {} }; const cambiarSucursal = async (id) => { setSucursalAdmin(id); try { await window.storage.set(SUCURSAL_KEY, id, false); } catch {} }; // guarda cambios y registra actividad cuando quien edita es un lavador const saveConLog = (seccion) => (next) => { if (sesion?.rol === "lavador") { const entry = { id: uid(), usuario: sesion.nombre, seccion, fecha: Date.now() }; const actividad = [entry, ...(data.actividad || [])].slice(0, 200); return save({ ...next, actividad }); } return save(next); }; if (!ready || !data || !sesionReady) { return (

Abriendo la llave de agua…

); } if (!sesion) { return ( ); } const esLavador = sesion.rol === "lavador"; const sucursales = data.sucursales && data.sucursales.length ? data.sucursales : DEFAULT_SUCURSALES; const sucursalId = esLavador ? (data.empleados.find((e) => e.nombre === sesion.nombre)?.sucursalId || sucursales[0].id) : (sucursalAdmin && sucursales.find((s) => s.id === sucursalAdmin) ? sucursalAdmin : sucursales[0].id); return (
setShowConfig(true)} sesion={sesion} onCerrarSesion={cerrarSesion} sucursales={sucursales} sucursalId={sucursalId} onCambiarSucursal={cambiarSucursal} esLavador={esLavador} /> {err && (
{err}
)}
{showConfig && !esLavador ? ( setShowConfig(false)} /> ) : ( <> {!esLavador && tab === "dashboard" && } {tab === "citas" && ( setTab("servicios")} sucursalId={sucursalId} /> )} {!esLavador && tab === "clientes" && } {!esLavador && tab === "fidelidad" && } {tab === "servicios" && ( )} {!esLavador && tab === "empleados" && } {tab === "ingresos" && } {tab === "inventario" && } )}
{!showConfig && } ); } /* ---------------- tema ---------------- */ const BG = "#0A2027"; const SURFACE = "#123138"; const SURFACE2 = "#17414B"; const ACCENT = "#35C9C1"; const GOLD = "#F4B740"; const TEXT = "#EAF6F6"; const MUTED = "#7FA8AE"; const DANGER = "#E4572E"; function Shell({ children }) { return (
{children}
); } function FontStyles() { return ( ); } function Card({ children, className = "" }) { return (
{children}
); } function Header({ data, save, onConfig, sesion, onCerrarSesion, sucursales, sucursalId, onCambiarSucursal, esLavador }) { const [editing, setEditing] = useState(false); const [name, setName] = useState(data.nombreNegocio); return (
{editing && !esLavador ? ( setName(e.target.value)} onBlur={() => { setEditing(false); save({ ...data, nombreNegocio: name || "Mi Autolavado" }); }} onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()} className="font-display text-lg font-bold rounded-lg px-2 py-1 outline-none" style={{ border: `1px solid ${ACCENT}` }} /> ) : (

!esLavador && setEditing(true)} className="font-display text-lg font-bold flex items-center gap-1.5" style={{ cursor: esLavador ? "default" : "pointer" }} > {data.nombreNegocio}

)}

{new Date().toLocaleDateString("es-MX", { weekday: "long", day: "numeric", month: "long" })} {esLavador ? ` · ${sesion.nombre}` : ""}

{!esLavador && sucursales && sucursales.length > 1 && ( )} {esLavador && sucursales && sucursales.length > 1 && (

{sucursales.find((s) => s.id === sucursalId)?.nombre}

)}
{!esLavador && ( )}
); } function SeleccionUsuario({ data, onElegir }) { const [pidiendoPin, setPidiendoPin] = useState(null); // { tipo: 'admin' | 'lavador', empleado? } const [pin, setPin] = useState(""); const [errorPin, setErrorPin] = useState(false); const intentarAdmin = () => { if (data.adminPin) { setPidiendoPin({ tipo: "admin" }); setPin(""); setErrorPin(false); } else { onElegir({ rol: "admin" }); } }; const intentarLavador = (e) => { if (e.pin) { setPidiendoPin({ tipo: "lavador", empleado: e }); setPin(""); setErrorPin(false); } else { onElegir({ rol: "lavador", nombre: e.nombre }); } }; const confirmarPin = () => { if (pidiendoPin.tipo === "admin") { if (pin === data.adminPin) onElegir({ rol: "admin" }); else setErrorPin(true); } else { if (pin === pidiendoPin.empleado.pin) onElegir({ rol: "lavador", nombre: pidiendoPin.empleado.nombre }); else setErrorPin(true); } }; if (pidiendoPin) { return (

{pidiendoPin.tipo === "admin" ? "Administrador" : pidiendoPin.empleado.nombre}

Ingresa tu PIN

{ setPin(e.target.value.replace(/\D/g, "").slice(0, 4)); setErrorPin(false); }} onKeyDown={(e) => e.key === "Enter" && confirmarPin()} className="w-32 text-center text-2xl font-mono tracking-[0.5em] rounded-xl px-3 py-2 outline-none" style={{ border: `1px solid ${errorPin ? DANGER : ACCENT}` }} placeholder="••••" /> {errorPin &&

PIN incorrecto, intenta de nuevo.

}
); } return (

{data.nombreNegocio}

¿Quién eres?

{data.empleados.length > 0 ? ( <>

O selecciona tu nombre si eres del equipo:

{data.empleados.map((e) => ( ))} ) : (

Agrega a tu equipo desde Configuración para que puedan entrar con su nombre.

)}
); } function NavBar({ tab, setTab, esLavador }) { const todos = [ { id: "dashboard", label: "Inicio", icon: Droplets }, { id: "citas", label: "Citas", icon: CalendarDays }, { id: "servicios", label: "Servicios", icon: Car }, { id: "clientes", label: "Clientes", icon: Users }, { id: "fidelidad", label: "Fidelidad", icon: Gift }, { id: "inventario", label: "Inventario", icon: Package }, { id: "empleados", label: "Equipo", icon: UserRound }, { id: "ingresos", label: "Finanzas", icon: Wallet }, ]; const permitidos = ["citas", "servicios", "inventario", "ingresos"]; const items = esLavador ? todos.filter((it) => permitidos.includes(it.id)) : todos; return (
{items.map((it) => { const active = tab === it.id; return ( ); })}
); } /* ---------------- Dashboard ---------------- */ /* ---------------- Configuración ---------------- */ function Configuracion({ data, save, onBack }) { const [nombre, setNombre] = useState(data.nombreNegocio); const [adminPin, setAdminPin] = useState(data.adminPin || ""); const [form, setForm] = useState(null); // {index, nombre, precio} const tipos = data.tiposServicio && data.tiposServicio.length ? data.tiposServicio : DEFAULT_TIPOS_SERVICIO; const guardarNombre = () => save({ ...data, nombreNegocio: nombre.trim() || "Mi Autolavado" }); const guardarAdminPin = () => save({ ...data, adminPin: adminPin.replace(/\D/g, "").slice(0, 4) }); const startNew = () => setForm({ index: null, nombre: "", precio: "" }); const startEdit = (t, i) => setForm({ index: i, nombre: t.nombre, precio: t.precio }); const guardarTipo = () => { if (!form.nombre.trim() || !form.precio) return; let nuevos; const entrada = { nombre: form.nombre.trim(), precio: Number(form.precio) }; if (form.index === null) nuevos = [...tipos, entrada]; else nuevos = tipos.map((t, i) => (i === form.index ? entrada : t)); save({ ...data, tiposServicio: nuevos }); setForm(null); }; const eliminarTipo = (i) => save({ ...data, tiposServicio: tipos.filter((_, idx) => idx !== i) }); const adicionales = data.serviciosAdicionales && data.serviciosAdicionales.length ? data.serviciosAdicionales : DEFAULT_ADICIONALES; const [formAdicional, setFormAdicional] = useState(null); // {index, nombre, precio} const startNewAdicional = () => setFormAdicional({ index: null, nombre: "", precio: "" }); const startEditAdicional = (t, i) => setFormAdicional({ index: i, nombre: t.nombre, precio: t.precio }); const guardarAdicional = () => { if (!formAdicional.nombre.trim() || !formAdicional.precio) return; let nuevos; const entrada = { nombre: formAdicional.nombre.trim(), precio: Number(formAdicional.precio) }; if (formAdicional.index === null) nuevos = [...adicionales, entrada]; else nuevos = adicionales.map((t, i) => (i === formAdicional.index ? entrada : t)); save({ ...data, serviciosAdicionales: nuevos }); setFormAdicional(null); }; const eliminarAdicional = (i) => save({ ...data, serviciosAdicionales: adicionales.filter((_, idx) => idx !== i) }); const sucursales = data.sucursales && data.sucursales.length ? data.sucursales : DEFAULT_SUCURSALES; const [formSucursal, setFormSucursal] = useState(null); // {id, nombre} const startNewSucursal = () => setFormSucursal({ id: null, nombre: "" }); const startEditSucursal = (s) => setFormSucursal({ id: s.id, nombre: s.nombre }); const guardarSucursal = () => { if (!formSucursal.nombre.trim()) return; let nuevas; if (formSucursal.id) nuevas = sucursales.map((s) => (s.id === formSucursal.id ? { ...s, nombre: formSucursal.nombre.trim() } : s)); else nuevas = [...sucursales, { id: uid(), nombre: formSucursal.nombre.trim() }]; save({ ...data, sucursales: nuevas }); setFormSucursal(null); }; const eliminarSucursal = (id) => { if (sucursales.length <= 1) return; save({ ...data, sucursales: sucursales.filter((s) => s.id !== id) }); }; const exportarJSON = () => descargarArchivo(`respaldo-${data.nombreNegocio}-${todayStr()}.json`, JSON.stringify(data, null, 2), "application/json"); const exportarServicios = () => { const csv = arrayToCsv(data.servicios, [ { label: "Fecha", value: "fecha" }, { label: "Cliente", value: "cliente" }, { label: "Placas", value: "placas" }, { label: "Vehículo", value: "tipoVehiculo" }, { label: "Servicio", value: "tipo" }, { label: "Precio", value: "precio" }, { label: "Propina", value: (r) => r.propina || 0 }, { label: "Método de pago", value: "metodo" }, { label: "Pagado", value: (r) => (r.pagado ? "Sí" : "No") }, { label: "Empleado", value: "empleado" }, { label: "Estado", value: (r) => (r.estado === "finalizado" ? "Finalizado" : "En progreso") }, ]); descargarArchivo(`servicios-${todayStr()}.csv`, csv, "text/csv"); }; const exportarGastos = () => { const csv = arrayToCsv(data.gastos || [], [ { label: "Fecha", value: "fecha" }, { label: "Concepto", value: "concepto" }, { label: "Categoría", value: "categoria" }, { label: "Monto", value: "monto" }, ]); descargarArchivo(`gastos-${todayStr()}.csv`, csv, "text/csv"); }; const exportarClientes = () => { const csv = arrayToCsv(data.clientes, [ { label: "Nombre", value: "nombre" }, { label: "Teléfono", value: "telefono" }, { label: "Vehículo", value: "vehiculo" }, { label: "Placas", value: "placas" }, { label: "Notas", value: "notas" }, ]); descargarArchivo(`clientes-${todayStr()}.csv`, csv, "text/csv"); }; return (

Nombre del negocio

setNombre(e.target.value)} onBlur={guardarNombre} onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()} className="flex-1 rounded-lg px-3 py-2 text-sm outline-none" />

PIN del administrador (opcional)

setAdminPin(e.target.value.replace(/\D/g, "").slice(0, 4))} onBlur={guardarAdminPin} onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()} placeholder="Déjalo vacío para no pedir PIN" className="rounded-lg px-3 py-2 text-sm outline-none tracking-[0.3em]" />

Si lo configuras, se pedirá cada vez que alguien elija "Soy el administrador".

Sucursales

{!formSucursal ? ( ) : ( setFormSucursal({ ...formSucursal, nombre: v })} placeholder="Ej. Sucursal Centro" /> setFormSucursal(null)} onSave={guardarSucursal} /> )} {sucursales.map((s) => (

{s.nombre}

startEditSucursal(s)}> {sucursales.length > 1 && ( eliminarSucursal(s.id)} danger> )}
))}

Servicios, gastos, citas, inventario y empleados se llevan por separado en cada sucursal. Clientes, tipos de servicio y fidelidad son compartidos entre todas.

Tipos de servicio

{!form ? ( ) : ( setForm({ ...form, nombre: v })} placeholder="Ej. Lavado Express" /> setForm({ ...form, precio: v })} /> setForm(null)} onSave={guardarTipo} /> )} {tipos.map((t, i) => (

{t.nombre}

{money(t.precio)}

startEdit(t, i)}> eliminarTipo(i)} danger>
))}

Servicios adicionales (+Cera, +Motor, etc.)

{!formAdicional ? ( ) : ( setFormAdicional({ ...formAdicional, nombre: v })} placeholder="Ej. Cera" /> setFormAdicional({ ...formAdicional, precio: v })} /> setFormAdicional(null)} onSave={guardarAdicional} /> )} {adicionales.map((t, i) => (

+ {t.nombre}

{money(t.precio)}

startEditAdicional(t, i)}> eliminarAdicional(i)} danger>
))}

Al registrar un servicio podrás marcar cualquiera de estos como extra; su precio se suma automáticamente al total.

Exportar y respaldo

Descarga tu información para verla en Excel o guardarla como respaldo.

Actividad del equipo

{(!data.actividad || data.actividad.length === 0) ? (

Aquí verás lo que hagan tus lavadores en Citas, Inventario y Finanzas cuando entren con su nombre.

) : (
{data.actividad.slice(0, 25).map((a) => (
{a.usuario} actualizó {a.seccion} {tiempoRelativo(a.fecha)}
))}
)}
); } /* ---------------- Dashboard ---------------- */ /* ---------------- Análisis de días y horarios ---------------- */ function AnalisisHorarios({ misServicios, onBack }) { const [periodo, setPeriodo] = useState("30dias"); const filtrados = useMemo(() => { if (periodo === "todo") return misServicios; const limite = Date.now() - 30 * 86400000; return misServicios.filter((s) => new Date(`${s.fecha}T00:00:00`).getTime() >= limite); }, [misServicios, periodo]); const NOMBRES_DIA = ["Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"]; const ORDEN_SEMANA = [1, 2, 3, 4, 5, 6, 0]; // lunes a domingo const porDia = useMemo(() => { const counts = new Array(7).fill(0); filtrados.forEach((s) => { const d = new Date(`${s.fecha}T00:00:00`).getDay(); counts[d]++; }); return ORDEN_SEMANA.map((i) => ({ dia: i, label: NOMBRES_DIA[i].slice(0, 3), nombre: NOMBRES_DIA[i], count: counts[i] })); }, [filtrados]); const maxDia = Math.max(1, ...porDia.map((d) => d.count)); const diaTop = porDia.reduce((a, b) => (b.count > a.count ? b : a), porDia[0]); const formatHora = (h) => { const per = h < 12 ? "AM" : "PM"; const h12 = h % 12 === 0 ? 12 : h % 12; return `${h12}${per}`; }; const porHora = useMemo(() => { const counts = new Array(24).fill(0); filtrados.forEach((s) => { if (!s.horaInicio) return; counts[new Date(s.horaInicio).getHours()]++; }); return counts; }, [filtrados]); const conDatos = porHora.map((c, h) => ({ h, c })).filter((x) => x.c > 0); const horaMin = conDatos.length ? Math.min(...conDatos.map((x) => x.h)) : 8; const horaMax = conDatos.length ? Math.max(...conDatos.map((x) => x.h)) : 20; const rangoHoras = []; for (let h = horaMin; h <= horaMax; h++) rangoHoras.push({ h, c: porHora[h] }); const maxHora = Math.max(1, ...rangoHoras.map((r) => r.c)); const horaTop = rangoHoras.length ? rangoHoras.reduce((a, b) => (b.c > a.c ? b : a)) : null; const totalServicios = filtrados.length; return (
{[["30dias", "Últimos 30 días"], ["todo", "Todo el historial"]].map(([id, label]) => ( ))}
{totalServicios === 0 ? (

No hay suficientes servicios registrados en este periodo para hacer el análisis.

) : ( <>

Tu día más ocupado es {diaTop.nombre} {horaTop && ( <> y tu horario pico es alrededor de las {formatHora(horaTop.h)} )} .

Autos por día de la semana

{porDia.map((d) => (
{d.count}
{d.label}
))}

Autos por horario del día

{rangoHoras.map((r) => (
{r.c > 0 && {r.c}}
{r.h}
))}

Hora del día (formato 24h)

Basado en {totalServicios} servicio{totalServicios !== 1 ? "s" : ""} registrado{totalServicios !== 1 ? "s" : ""} en este periodo. Te puede servir para programar tu equipo y tener más apoyo en tus días/horarios de mayor demanda.

)}
); } /* ---------------- Dashboard ---------------- */ function Dashboard({ data, save, sucursalId }) { const sucursales = data.sucursales && data.sucursales.length ? data.sucursales : DEFAULT_SUCURSALES; const misServicios = data.servicios.filter((s) => pertenece(s, sucursalId, sucursales)); const misGastos = (data.gastos || []).filter((g) => pertenece(g, sucursalId, sucursales)); const misInventario = (data.inventario || []).filter((p) => pertenece(p, sucursalId, sucursales)); const misCitas = (data.citas || []).filter((c) => pertenece(c, sucursalId, sucursales)); const hoy = todayStr(); const serviciosHoy = misServicios.filter((s) => s.fecha === hoy); const ingresoHoy = serviciosHoy.reduce((a, s) => a + Number(s.precio), 0); const pendientes = misServicios.filter((s) => !s.pagado); const pendienteMonto = pendientes.reduce((a, s) => a + Number(s.precio), 0); const gastosHoy = misGastos.filter((g) => g.fecha === hoy).reduce((a, g) => a + Number(g.monto), 0); const netoHoy = ingresoHoy - gastosHoy; const efectivoIngresoHoy = serviciosHoy .filter((s) => s.pagado && (s.metodo || "efectivo") === "efectivo") .reduce((a, s) => a + Number(s.precio), 0); const efectivoGastoHoy = misGastos .filter((g) => g.fecha === hoy && (g.metodo || "efectivo") === "efectivo") .reduce((a, g) => a + Number(g.monto), 0); const efectivoCajaHoy = efectivoIngresoHoy - efectivoGastoHoy; const propinaHoy = serviciosHoy.reduce((a, s) => a + Number(s.propina || 0), 0); const enProgresoCount = misServicios.filter((s) => s.estado !== "finalizado").length; const pct = Math.min(100, Math.round((ingresoHoy / (data.metaDiaria || 1)) * 100)); const last7 = useMemo(() => { const arr = []; for (let i = 6; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); const key = d.toISOString().split("T")[0]; const total = misServicios .filter((s) => s.fecha === key) .reduce((a, s) => a + Number(s.precio), 0); arr.push({ key, total, label: d.toLocaleDateString("es-MX", { weekday: "short" })[0].toUpperCase() }); } return arr; }, [misServicios]); const maxDia = Math.max(1, ...last7.map((d) => d.total)); const empleadoTop = useMemo(() => { const mes = hoy.slice(0, 7); const counts = {}; misServicios .filter((s) => s.fecha.startsWith(mes) && s.empleado) .forEach((s) => { counts[s.empleado] = (counts[s.empleado] || 0) + 1; }); const arr = Object.entries(counts).sort((a, b) => b[1] - a[1]); return arr[0]; }, [misServicios]); const stockBajo = misInventario.filter((p) => Number(p.stock) <= Number(p.minimo)); const citasHoy = misCitas.filter((c) => c.fecha === hoy && c.estado === "pendiente").sort((a, b) => (a.hora < b.hora ? -1 : 1)); const citaTimestamp = (c) => new Date(`${c.fecha}T${c.hora}:00`).getTime(); const citasProximaHora = citasHoy.filter((c) => { const t = citaTimestamp(c); return t >= Date.now() && t - Date.now() <= 3600000; }); const mananaStr = (() => { const d = new Date(); d.setDate(d.getDate() + 1); return d.toISOString().split("T")[0]; })(); const citasSinConfirmar = misCitas.filter((c) => c.fecha === mananaStr && c.estado === "pendiente" && !c.confirmada); const [editandoMeta, setEditandoMeta] = useState(false); const [metaInput, setMetaInput] = useState(data.metaDiaria); // alerta sonora/visual mientras la app está abierta (no es un push real: eso requiere backend/service worker, // algo que este tipo de app no puede tener; esta es la mejor alternativa dentro de lo posible aquí) const avisadasRef = useRef(new Set()); useEffect(() => { citasProximaHora.forEach((c) => { if (avisadasRef.current.has(c.id)) return; avisadasRef.current.add(c.id); try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "sine"; osc.frequency.value = 880; gain.gain.setValueAtTime(0.15, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 0.5); } catch {} if (typeof Notification !== "undefined" && Notification.permission === "granted") { try { new Notification("Cita muy pronto", { body: `${c.cliente} · ${c.servicio} a las ${c.hora}` }); } catch {} } }); }, [citasProximaHora]); const [permisoNotif, setPermisoNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "unsupported"); const pedirPermisoNotif = () => { if (typeof Notification === "undefined") return; Notification.requestPermission().then((p) => setPermisoNotif(p)); }; const guardarMeta = () => { setEditandoMeta(false); const valor = Number(metaInput); if (valor > 0) save({ ...data, metaDiaria: valor }); else setMetaInput(data.metaDiaria); }; const [mostrarAnalisis, setMostrarAnalisis] = useState(false); if (mostrarAnalisis) { return setMostrarAnalisis(false)} />; } return (
{permisoNotif === "default" && ( )} {/* medidor de agua */}

Meta del día

{money(ingresoHoy)}

{editandoMeta ? (
de $ setMetaInput(e.target.value)} onBlur={guardarMeta} onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()} className="w-20 rounded-md px-1.5 py-0.5 text-xs outline-none" style={{ border: `1px solid ${ACCENT}` }} />
) : ( )}

Efectivo en caja hoy

{money(efectivoCajaHoy)}

{ingresoHoy - efectivoIngresoHoy > 0 && (

+{money(ingresoHoy - efectivoIngresoHoy)} en transferencia/tarjeta (no cuenta para la caja)

)}

{serviciosHoy.length}

servicios hoy{enProgresoCount > 0 ? ` · ${enProgresoCount} en curso` : ""}

{money(pendienteMonto)}

{pendientes.length} por cobrar

{money(propinaHoy)}

propinas de hoy

{money(gastosHoy)}

gastos de hoy

= 0 ? "#6FE0A8" : DANGER} />

= 0 ? TEXT : DANGER }}> {money(netoHoy)}

ganancia neta hoy

Últimos 7 días

{last7.map((d) => (
{d.label}
))}
{citasProximaHora.length > 0 && (

¡Cita muy pronto!

{citasProximaHora.map((c) => (

{c.cliente} · {c.servicio} — {tiempoHasta(citaTimestamp(c))} ({c.hora})

))}
)} {citasSinConfirmar.length > 0 && (

Confirma las citas de mañana

{citasSinConfirmar.map((c) => (

{c.hora} · {c.cliente}{c.telefono ? ` · ${c.telefono}` : ""}

))}
)} {citasHoy.length > 0 && (

Citas de hoy

{citasHoy.map((c) => { const t = citaTimestamp(c); const pronto = t >= Date.now() && t - Date.now() <= 3600000; return (
{c.hora} · {c.cliente} · {c.servicio} {pronto && {tiempoHasta(t)}}
); })}
)} {empleadoTop ? (

{empleadoTop[0]} lleva el mes con{" "} {empleadoTop[1]} servicios

) : (

Registra tu primer servicio para ver aquí el progreso del equipo.

)} {stockBajo.length > 0 && (

Stock bajo

{stockBajo.map((p) => p.nombre).join(", ")} {stockBajo.length === 1 ? "está por agotarse" : "están por agotarse"}.

)}
); } function WaterGauge({ pct }) { const size = 72; const waterY = size - (size * pct) / 100; return ( {pct}% ); } /* ---------------- Clientes ---------------- */ /* ---------------- Citas ---------------- */ function Citas({ data, save, onIniciarServicio, sucursalId }) { const [form, setForm] = useState(null); const sucursales = data.sucursales && data.sucursales.length ? data.sucursales : DEFAULT_SUCURSALES; const citas = (data.citas || []).filter((c) => pertenece(c, sucursalId, sucursales)); const misEmpleados = data.empleados.filter((e) => pertenece(e, sucursalId, sucursales)); const tipos = data.tiposServicio && data.tiposServicio.length ? data.tiposServicio : DEFAULT_TIPOS_SERVICIO; const blank = () => ({ id: null, cliente: "", telefono: "", tipoVehiculo: TIPOS_VEHICULO[0], servicio: tipos[0].nombre, fecha: todayStr(), hora: "09:00", notas: "", estado: "pendiente", confirmada: false, }); const startNew = () => setForm(blank()); const guardar = () => { if (!form.cliente.trim()) return; let nuevas; if (form.id) nuevas = data.citas.map((c) => (c.id === form.id ? form : c)); else nuevas = [...data.citas, { ...form, id: uid(), sucursalId }]; save({ ...data, citas: nuevas }); setForm(null); }; const eliminar = (id) => save({ ...data, citas: data.citas.filter((c) => c.id !== id) }); const cambiarEstado = (c, estado) => save({ ...data, citas: data.citas.map((x) => (x.id === c.id ? { ...x, estado } : x)) }); const confirmar = (c) => save({ ...data, citas: data.citas.map((x) => (x.id === c.id ? { ...x, confirmada: true } : x)) }); const iniciarServicio = (c) => { const tipoInfo = tipos.find((t) => t.nombre === c.servicio); const cliente = data.clientes.find((cl) => cl.nombre.trim().toLowerCase() === c.cliente.trim().toLowerCase()); const nuevoServicio = { id: uid(), fecha: todayStr(), cliente: c.cliente, placas: cliente?.placas || "", tipoVehiculo: c.tipoVehiculo, tipo: c.servicio, precio: tipoInfo ? tipoInfo.precio : 0, empleado: misEmpleados[0]?.nombre || "", metodo: "efectivo", pagado: false, estado: "en_progreso", horaInicio: Date.now(), horaFin: null, propina: 0, sucursalId, }; save({ ...data, servicios: [nuevoServicio, ...data.servicios], citas: data.citas.map((x) => (x.id === c.id ? { ...x, estado: "atendida" } : x)), }); onIniciarServicio && onIniciarServicio(); }; const ordenadas = [...citas].sort((a, b) => (a.fecha + a.hora < b.fecha + b.hora ? -1 : 1)); const hoy = todayStr(); const proximas = ordenadas.filter((c) => c.estado === "pendiente" && c.fecha >= hoy); const pasadas = ordenadas.filter((c) => c.estado !== "pendiente" || c.fecha < hoy); const badge = (estado) => { if (estado === "atendida") return { bg: "#1E4A3A", color: "#6FE0A8", label: "Atendida" }; if (estado === "cancelada") return { bg: "#3A1E1E", color: "#F4B0A0", label: "Cancelada" }; return { bg: "#4A3B1E", color: GOLD, label: "Pendiente" }; }; const CitaCard = ({ c }) => { const b = badge(c.estado); return (

{c.cliente}

{c.tipoVehiculo} · {c.servicio}

{fecha(c.fecha)} · {c.hora}

{c.estado === "pendiente" && (

{c.confirmada ? "✓ Confirmada" : "Sin confirmar"}

)} {c.notas &&

{c.notas}

}
{b.label}
setForm(c)}> eliminar(c.id)} danger>
{c.estado === "pendiente" && (
{!c.confirmada && ( )}
)}
); }; return (
{!form ? ( ) : (
{ const val = e.target.value; const match = data.clientes.find((cl) => cl.nombre.trim().toLowerCase() === val.trim().toLowerCase()); setForm({ ...form, cliente: val, telefono: match ? match.telefono || form.telefono : form.telefono, }); }} placeholder="Nombre del cliente" className="w-full rounded-lg px-3 py-2 text-sm outline-none mt-1" /> {data.clientes.map((c) =>
{(() => { const match = data.clientes.find((cl) => cl.nombre.trim().toLowerCase() === form.cliente.trim().toLowerCase()); return match?.notas ? (
{match.notas}
) : null; })()} setForm({ ...form, telefono: v })} />
setForm({ ...form, fecha: v })} />
setForm({ ...form, hora: v })} />
setForm({ ...form, notas: v })} /> setForm(null)} onSave={guardar} />
)} {proximas.length === 0 && pasadas.length === 0 && !form && (

No tienes citas agendadas. Usa el botón de arriba para agregar la primera.

)} {proximas.length > 0 && ( <>

Próximas

{proximas.map((c) => )} )} {pasadas.length > 0 && ( <>

Historial

{pasadas.map((c) => )} )}
); } /* ---------------- Fidelidad ---------------- */ function Fidelidad({ data, save }) { const [vistaPrincipal, setVistaPrincipal] = useState("sellos"); const [q, setQ] = useState(""); const [form, setForm] = useState(null); // {id, nombre, metaSellos, premio} programa a crear/editar const programas = data.fidelidad && data.fidelidad.length ? data.fidelidad : DEFAULT_FIDELIDAD; const [activoId, setActivoId] = useState(programas[0]?.id); useEffect(() => { if (!programas.find((p) => p.id === activoId) && programas[0]) setActivoId(programas[0].id); }, [data.fidelidad]); const programaActivo = programas.find((p) => p.id === activoId) || programas[0]; const idxActivo = programas.findIndex((p) => p.id === programaActivo?.id); const startNewPrograma = () => setForm({ id: null, nombre: "", metaSellos: "", premio: "" }); const startEditPrograma = (p) => setForm({ id: p.id, nombre: p.nombre, metaSellos: p.metaSellos, premio: p.premio }); const guardarPrograma = () => { if (!form.nombre.trim() || !form.metaSellos || !form.premio.trim()) return; let nuevos; if (form.id) { nuevos = programas.map((p) => (p.id === form.id ? { ...p, nombre: form.nombre.trim(), metaSellos: Number(form.metaSellos), premio: form.premio.trim() } : p)); } else { const nuevo = { id: uid(), nombre: form.nombre.trim(), metaSellos: Number(form.metaSellos), premio: form.premio.trim() }; nuevos = [...programas, nuevo]; setActivoId(nuevo.id); } save({ ...data, fidelidad: nuevos }); setForm(null); }; const eliminarPrograma = (id) => { const nuevos = programas.filter((p) => p.id !== id); save({ ...data, fidelidad: nuevos }); setForm(null); }; const actualizarProgreso = (c, valor) => { const progreso = { ...(c.progreso || {}) }; progreso[programaActivo.id] = Math.max(0, valor); save({ ...data, clientes: data.clientes.map((x) => (x.id === c.id ? { ...x, progreso } : x)) }); }; const sumar = (c) => actualizarProgreso(c, getSellos(c, programaActivo, idxActivo) + 1); const restar = (c) => actualizarProgreso(c, getSellos(c, programaActivo, idxActivo) - 1); const canjear = (c) => actualizarProgreso(c, 0); const filtrados = data.clientes.filter((c) => c.nombre.toLowerCase().includes(q.toLowerCase())); const meta = Number(programaActivo?.metaSellos) || 1; const conPremio = programaActivo ? filtrados.filter((c) => getSellos(c, programaActivo, idxActivo) >= meta) : []; const enProgreso = programaActivo ? filtrados.filter((c) => getSellos(c, programaActivo, idxActivo) < meta) : []; const Punchcard = ({ c }) => { const sellos = getSellos(c, programaActivo, idxActivo); const lista = Array.from({ length: meta }, (_, i) => i < sellos); const listo = sellos >= meta; return (

{c.nombre}

{sellos}/{meta} servicios

{listo && ( Premio listo )}
{lista.map((lleno, i) => (
{lleno && }
))}
{listo && ( )}
); }; return (
{vistaPrincipal === "membresias" ? ( ) : ( <> {/* selector de programas */}
{programas.map((p) => ( ))}
{!form && programaActivo && (

Este programa

{programaActivo.metaSellos} servicios · {programaActivo.premio}

startEditPrograma(programaActivo)}> eliminarPrograma(programaActivo.id)} danger>
)} {form && ( setForm({ ...form, nombre: v })} placeholder="Ej. 5 lavados" /> setForm({ ...form, metaSellos: v })} /> setForm({ ...form, premio: v })} placeholder="Ej. 50% de descuento" /> setForm(null)} onSave={guardarPrograma} /> )} {!programaActivo && !form && (

Agrega tu primer programa de fidelidad con el botón de arriba.

)} {programaActivo && ( <>
setQ(e.target.value)} placeholder="Buscar cliente…" className="bg-transparent flex-1 py-2.5 text-sm outline-none" />
{filtrados.length === 0 && (

{data.clientes.length === 0 ? "Registra clientes desde Servicios o Clientes para empezar a llevar su tarjeta de fidelidad." : "No hay clientes que coincidan con tu búsqueda."}

)} {conPremio.length > 0 && ( <>

Con premio disponible

{conPremio.map((c) => )} )} {enProgreso.length > 0 && ( <>

En progreso

{enProgreso.map((c) => )} )} )} )}
); } function Membresias({ data, save }) { const [form, setForm] = useState(null); // {id, nombre, precio, duracionDias, lavadosIncluidos} const [asignandoId, setAsignandoId] = useState(null); const [planElegido, setPlanElegido] = useState(""); const [q, setQ] = useState(""); const planes = data.membresias || []; const startNew = () => setForm({ id: null, nombre: "", precio: "", duracionDias: "30", lavadosIncluidos: "" }); const startEdit = (p) => setForm({ id: p.id, nombre: p.nombre, precio: p.precio, duracionDias: p.duracionDias, lavadosIncluidos: p.lavadosIncluidos || "" }); const guardarPlan = () => { if (!form.nombre.trim() || !form.precio || !form.duracionDias) return; const entrada = { id: form.id || uid(), nombre: form.nombre.trim(), precio: Number(form.precio), duracionDias: Number(form.duracionDias), lavadosIncluidos: form.lavadosIncluidos ? Number(form.lavadosIncluidos) : 0, // 0 = ilimitado }; let nuevos; if (form.id) nuevos = planes.map((p) => (p.id === form.id ? entrada : p)); else nuevos = [...planes, entrada]; save({ ...data, membresias: nuevos }); setForm(null); }; const eliminarPlan = (id) => save({ ...data, membresias: planes.filter((p) => p.id !== id) }); const asignar = (cliente) => { const plan = planes.find((p) => p.id === planElegido); if (!plan) return; const fechaInicio = todayStr(); const fin = new Date(); fin.setDate(fin.getDate() + plan.duracionDias); const membresia = { planId: plan.id, fechaInicio, fechaFin: fin.toISOString().split("T")[0], lavadosUsados: 0 }; save({ ...data, clientes: data.clientes.map((c) => (c.id === cliente.id ? { ...c, membresia } : c)) }); setAsignandoId(null); setPlanElegido(""); }; const cancelar = (cliente) => save({ ...data, clientes: data.clientes.map((c) => (c.id === cliente.id ? { ...c, membresia: null } : c)) }); const estadoDe = (cliente) => { const m = cliente.membresia; if (!m) return null; const plan = planes.find((p) => p.id === m.planId); const vencida = m.fechaFin < todayStr(); const agotada = plan && plan.lavadosIncluidos > 0 && m.lavadosUsados >= plan.lavadosIncluidos; return { m, plan, vencida, agotada, activa: !vencida && !agotada }; }; const filtrados = data.clientes.filter((c) => c.nombre.toLowerCase().includes(q.toLowerCase())); return (
{!form ? ( ) : ( setForm({ ...form, nombre: v })} placeholder="Ej. Lavados ilimitados" /> setForm({ ...form, precio: v })} /> setForm({ ...form, duracionDias: v })} placeholder="Ej. 30" /> setForm({ ...form, lavadosIncluidos: v })} /> setForm(null)} onSave={guardarPlan} /> )} {planes.length === 0 && !form && (

Aún no tienes planes de membresía.

)} {planes.map((p) => (

{p.nombre}

{money(p.precio)} · {p.duracionDias} días · {p.lavadosIncluidos > 0 ? `${p.lavadosIncluidos} lavados` : "Ilimitados"}

startEdit(p)}> eliminarPlan(p.id)} danger>
))} {planes.length > 0 && ( <>

Membresías de clientes

setQ(e.target.value)} placeholder="Buscar cliente…" className="bg-transparent flex-1 py-2.5 text-sm outline-none" />
{filtrados.map((c) => { const est = estadoDe(c); return (

{c.nombre}

{est ? ( <>

{est.plan?.nombre || "Plan eliminado"}

{est.vencida ? "Vencida" : est.agotada ? "Lavados agotados" : "Activa"} · vence {fecha(est.m.fechaFin)} {est.plan?.lavadosIncluidos > 0 ? ` · ${est.m.lavadosUsados}/${est.plan.lavadosIncluidos} usados` : ""}

) : (

Sin membresía

)}
{est && ( )}
{asignandoId === c.id ? (
setAsignandoId(null)} onSave={() => asignar(c)} />
) : ( )}
); })} )}
); } function Clientes({ data, save }) { const [q, setQ] = useState(""); const [form, setForm] = useState(null); const [verHistorial, setVerHistorial] = useState(null); // id de cliente const [verInactivos, setVerInactivos] = useState(false); const filtered = data.clientes.filter((c) => (c.nombre + c.vehiculo + c.placas).toLowerCase().includes(q.toLowerCase()) ); const startNew = () => setForm({ id: null, nombre: "", telefono: "", vehiculo: "", placas: "", sellos: 0, notas: "" }); const guardar = () => { if (!form.nombre.trim()) return; let clientes; if (form.id) { clientes = data.clientes.map((c) => (c.id === form.id ? form : c)); } else { clientes = [...data.clientes, { ...form, id: uid() }]; } save({ ...data, clientes }); setForm(null); }; const eliminar = (id) => save({ ...data, clientes: data.clientes.filter((c) => c.id !== id) }); // servicios asociados a un cliente: por nombre, y también por placa (cubre el caso de que el auto cambie de dueño) const historialDe = (c) => { const porPlaca = c.placas ? data.servicios.filter((s) => s.placas && s.placas.trim().toUpperCase() === c.placas.trim().toUpperCase()) : []; const porNombre = data.servicios.filter((s) => s.cliente === c.nombre); const mapa = new Map(); [...porPlaca, ...porNombre].forEach((s) => mapa.set(s.id, s)); return [...mapa.values()].sort((a, b) => (a.fecha < b.fecha ? 1 : -1)); }; const statsFor = (c) => { const propios = historialDe(c); const ultimaVisita = propios.reduce((max, s) => (!max || s.fecha > max ? s.fecha : max), null); return { visitas: propios.length, gastado: propios.reduce((a, s) => a + Number(s.precio), 0), ultimaVisita, servicios: propios }; }; const inactivos = useMemo(() => { const hoy = todayStr(); return data.clientes .map((c) => ({ c, st: statsFor(c) })) .filter(({ st }) => st.ultimaVisita && diasEntre(st.ultimaVisita, hoy) >= 30) .sort((a, b) => diasEntre(b.st.ultimaVisita, hoy) - diasEntre(a.st.ultimaVisita, hoy)); }, [data.clientes, data.servicios]); const porMes = useMemo(() => { const primeraVisita = {}; data.servicios.forEach((s) => { if (!primeraVisita[s.cliente] || s.fecha < primeraVisita[s.cliente]) primeraVisita[s.cliente] = s.fecha; }); const meses = []; for (let i = 5; i >= 0; i--) { const d = new Date(); d.setMonth(d.getMonth() - i); meses.push({ key: d.toISOString().slice(0, 7), label: d.toLocaleDateString("es-MX", { month: "short" }) }); } return meses.map(({ key, label }) => { const clientesDelMes = new Set(data.servicios.filter((s) => s.fecha.slice(0, 7) === key).map((s) => s.cliente)); let nuevos = 0, recurrentes = 0; clientesDelMes.forEach((c) => { if (primeraVisita[c] && primeraVisita[c].slice(0, 7) === key) nuevos++; else recurrentes++; }); return { key, label, nuevos, recurrentes }; }); }, [data.servicios]); const maxMes = Math.max(1, ...porMes.map((m) => m.nuevos + m.recurrentes)); return (
{data.servicios.length > 0 && (

Clientes nuevos vs. recurrentes (últimos 6 meses)

{porMes.map((m) => { const total = m.nuevos + m.recurrentes; const alturaTotal = Math.max(total > 0 ? 10 : 0, (total / maxMes) * 80); const alturaNuevos = total > 0 ? (m.nuevos / total) * alturaTotal : 0; const alturaRecurrentes = alturaTotal - alturaNuevos; return (
{alturaNuevos > 0 &&
} {alturaRecurrentes > 0 && (
)}
{m.label}
); })}

Nuevos

Recurrentes

)} {inactivos.length > 0 && ( {verInactivos && (
{inactivos.map(({ c, st }) => (

{c.nombre}{c.telefono ? ` · ${c.telefono}` : ""}

Última visita: {fecha(st.ultimaVisita)} ({diasEntre(st.ultimaVisita, todayStr())} días)

))}
)}
)}
setQ(e.target.value)} placeholder="Buscar cliente o placa…" className="bg-transparent flex-1 py-2.5 text-sm outline-none" />
{form && ( setForm({ ...form, nombre: v })} /> setForm({ ...form, telefono: v })} /> setForm({ ...form, vehiculo: v })} placeholder="Ej. Nissan Versa gris" /> setForm({ ...form, placas: v })} />