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 && (
onCambiarSucursal(e.target.value)}
className="text-xs mt-1 rounded-md px-1.5 py-0.5 outline-none"
style={{ background: SURFACE2, color: ACCENT, border: `1px solid ${SURFACE2}` }}
>
{sucursales.map((s) => {s.nombre} )}
)}
{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.
}
Entrar
setPidiendoPin(null)}
style={{ color: MUTED }}
className="w-full rounded-xl py-2 text-sm"
>
Cancelar
);
}
return (
{data.nombreNegocio}
¿Quién eres?
Soy el administrador
{data.empleados.length > 0 ? (
<>
O selecciona tu nombre si eres del equipo:
{data.empleados.map((e) => (
intentarLavador(e)}
className="w-full rounded-xl py-3 text-sm flex items-center justify-center gap-2"
style={{ background: SURFACE, border: `1px solid ${SURFACE2}`, color: TEXT }}
>
{e.nombre}
))}
>
) : (
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 (
setTab(it.id)}
className="flex flex-col items-center gap-0.5 px-2 py-1 rounded-xl transition-colors shrink-0"
style={{ color: active ? ACCENT : MUTED, minWidth: "52px" }}
>
{it.label}
);
})}
);
}
/* ---------------- 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 (
Regresar
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 ? (
Agregar sucursal
) : (
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 ? (
Agregar tipo de servicio
) : (
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 ? (
Agregar servicio adicional
) : (
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.
Servicios (CSV)
Gastos (CSV)
Clientes (CSV)
Respaldo completo (JSON)
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 (
Regresar
{[["30dias", "Últimos 30 días"], ["todo", "Todo el historial"]].map(([id, label]) => (
setPeriodo(id)}
style={{ background: periodo === id ? ACCENT : SURFACE2, color: periodo === id ? BG : TEXT }}
className="flex-1 rounded-lg py-2 text-xs font-medium"
>
{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
Autos por horario del día
{rangoHoras.map((r) => (
))}
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" && (
Activar aviso de citas próximas (mientras tengas la app abierta)
)}
{/* 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}` }}
/>
) : (
{ setMetaInput(data.metaDiaria); setEditandoMeta(true); }}
className="text-xs flex items-center gap-1 mt-0.5"
style={{ color: MUTED }}
>
de {money(data.metaDiaria)} · {pct}%
)}
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
setMostrarAnalisis(true)}
className="w-full rounded-xl py-2.5 text-sm font-semibold flex items-center justify-center gap-1.5"
style={{ background: SURFACE2, color: ACCENT }}
>
Ver días y horarios con más autos
{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}` : ""}
save({ ...data, citas: data.citas.map((x) => (x.id === c.id ? { ...x, confirmada: true } : x)) })}
className="text-[10px] px-2 py-1 rounded-full font-medium"
style={{ background: "#1E4A3A", color: "#6FE0A8" }}
>
Confirmar
))}
)}
{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 && (
confirmar(c)}
className="rounded-lg py-2 px-3 text-xs font-medium flex items-center justify-center gap-1"
style={{ background: SURFACE2, color: GOLD }}
>
)}
iniciarServicio(c)}
className="flex-1 rounded-lg py-2 text-xs font-semibold flex items-center justify-center gap-1"
style={{ background: ACCENT, color: BG }}
>
Iniciar servicio
cambiarEstado(c, "cancelada")}
className="rounded-lg py-2 px-3 text-xs font-medium flex items-center justify-center gap-1"
style={{ background: SURFACE2, color: MUTED }}
>
)}
);
};
return (
{!form ? (
Agendar cita
) : (
Cliente
{
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 })} />
Tipo de vehículo
setForm({ ...form, tipoVehiculo: e.target.value })}
className="w-full rounded-lg px-3 py-2 text-sm outline-none mt-1"
>
{TIPOS_VEHICULO.map((v) => {v} )}
Servicio
setForm({ ...form, servicio: e.target.value })}
className="w-full rounded-lg px-3 py-2 text-sm outline-none mt-1"
>
{tipos.map((t) => {t.nombre} )}
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 && }
))}
restar(c)} className="rounded-lg py-2 px-3 text-xs flex items-center justify-center" style={{ background: SURFACE2, color: MUTED }}>
sumar(c)} className="flex-1 rounded-lg py-2 text-xs font-medium flex items-center justify-center gap-1" style={{ background: SURFACE2, color: ACCENT }}>
Sello
{listo && (
canjear(c)} className="flex-1 rounded-lg py-2 text-xs font-semibold flex items-center justify-center gap-1" style={{ background: GOLD, color: BG }}>
Canjear
)}
);
};
return (
setVistaPrincipal("sellos")}
style={{ background: vistaPrincipal === "sellos" ? ACCENT : "transparent", color: vistaPrincipal === "sellos" ? BG : TEXT }}
className="flex-1 rounded-lg py-2 text-xs font-semibold flex items-center justify-center gap-1.5"
>
Sellos
setVistaPrincipal("membresias")}
style={{ background: vistaPrincipal === "membresias" ? ACCENT : "transparent", color: vistaPrincipal === "membresias" ? BG : TEXT }}
className="flex-1 rounded-lg py-2 text-xs font-semibold flex items-center justify-center gap-1.5"
>
Membresías
{vistaPrincipal === "membresias" ? (
) : (
<>
{/* selector de programas */}
{programas.map((p) => (
setActivoId(p.id)}
className="shrink-0 rounded-xl px-3 py-2 text-xs font-semibold whitespace-nowrap"
style={{
background: p.id === programaActivo?.id ? ACCENT : SURFACE2,
color: p.id === programaActivo?.id ? BG : TEXT,
}}
>
{p.nombre}
))}
Programa
{!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 ? (
Nuevo plan
) : (
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 && (
cancelar(c)} className="text-xs px-2 py-1 rounded-full" style={{ background: SURFACE2, color: DANGER }}>
Cancelar
)}
{asignandoId === c.id ? (
setPlanElegido(e.target.value)}
className="w-full rounded-lg px-3 py-2 text-sm outline-none"
>
Selecciona un plan
{planes.map((p) => {p.nombre} · {money(p.precio)} )}
setAsignandoId(null)} onSave={() => asignar(c)} />
) : (
{ setAsignandoId(c.id); setPlanElegido(""); }}
className="text-xs font-medium mt-2.5 pt-2.5 flex items-center gap-1 w-full"
style={{ color: ACCENT, borderTop: `1px solid ${SURFACE2}` }}
>
{est ? "Renovar / cambiar plan" : "Asignar membresía"}
)}
);
})}
>
)}
);
}
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}
);
})}
)}
{inactivos.length > 0 && (
setVerInactivos(!verInactivos)} className="w-full flex items-center justify-between">
{inactivos.length} cliente{inactivos.length > 1 ? "s" : ""} inactivo{inactivos.length > 1 ? "s" : ""} (30+ días)
{verInactivos && (
{inactivos.map(({ c, st }) => (
{c.nombre}{c.telefono ? ` · ${c.telefono}` : ""}
Última visita: {fecha(st.ultimaVisita)} ({diasEntre(st.ultimaVisita, todayStr())} días)
))}
)}
)}
{form && (
setForm({ ...form, nombre: v })} />
setForm({ ...form, telefono: v })} />
setForm({ ...form, vehiculo: v })} placeholder="Ej. Nissan Versa gris" />
setForm({ ...form, placas: v })} />
)}
{filtered.length === 0 && !form && (
{data.clientes.length === 0
? "Aún no tienes clientes. Agrega el primero con el botón +."
: "No hay clientes que coincidan con tu búsqueda."}
)}
{filtered.map((c) => {
const st = statsFor(c);
const expandido = verHistorial === c.id;
return (
{c.nombre}
{c.vehiculo}{c.placas ? ` · ${c.placas}` : ""}
{st.visitas} visitas · {money(st.gastado)}
{st.ultimaVisita && (
Última visita: {fecha(st.ultimaVisita)}
)}
{c.notas && (
{c.notas}
)}
setForm(c)}>
eliminar(c.id)} danger>
{st.visitas > 0 && (
setVerHistorial(expandido ? null : c.id)}
className="text-xs font-medium mt-2.5 pt-2.5 flex items-center gap-1 w-full"
style={{ color: ACCENT, borderTop: `1px solid ${SURFACE2}` }}
>
{expandido ? "Ocultar historial" : `Ver historial (${st.visitas})`}
)}
{expandido && (
{st.servicios.map((s) => (
{fecha(s.fecha)} · {s.tipo}
{s.cliente !== c.nombre ? ` · ${s.cliente}` : ""}
{money(s.precio)}
))}
)}
);
})}
);
}
/* ---------------- Servicios ---------------- */
function Servicios({ data, save, sucursalId, miNombre }) {
const [showForm, setShowForm] = useState(false);
const sucursales = data.sucursales && data.sucursales.length ? data.sucursales : DEFAULT_SUCURSALES;
const misEmpleados = data.empleados.filter((e) => pertenece(e, sucursalId, sucursales));
const misServicios = data.servicios.filter((s) => pertenece(s, sucursalId, sucursales));
const [form, setForm] = useState(blankServicio());
function blankServicio() {
const tipos = data.tiposServicio && data.tiposServicio.length ? data.tiposServicio : DEFAULT_TIPOS_SERVICIO;
return {
id: null,
fecha: todayStr(),
cliente: "",
placas: "",
tipoVehiculo: TIPOS_VEHICULO[0],
tipo: tipos[0].nombre,
precio: tipos[0].precio,
extras: [],
empleado: misEmpleados[0]?.nombre || "",
metodo: "efectivo",
pagado: false,
estado: "en_progreso",
horaInicio: null,
horaFin: null,
propina: 0,
};
}
const abrirEditarServicio = (s) => {
setForm({ extras: [], ...s });
setShowForm(true);
};
const toggleExtra = (extra) => {
const yaEsta = (form.extras || []).some((e) => e.nombre === extra.nombre);
if (yaEsta) {
setForm({ ...form, extras: form.extras.filter((e) => e.nombre !== extra.nombre), precio: Math.max(0, Number(form.precio) - Number(extra.precio)) });
} else {
setForm({ ...form, extras: [...(form.extras || []), extra], precio: Number(form.precio) + Number(extra.precio) });
}
};
const guardar = () => {
if (!form.cliente.trim()) return;
let servicios;
const clientesActualizados = form.id
? data.clientes
: upsertCliente(data.clientes, form.cliente, form.placas, form.tipoVehiculo);
if (form.id) {
servicios = data.servicios.map((s) => (s.id === form.id ? form : s));
} else {
servicios = [{ ...form, id: uid(), estado: "en_progreso", horaInicio: Date.now(), horaFin: null, sucursalId }, ...data.servicios];
}
save({ ...data, servicios, clientes: clientesActualizados });
setForm(blankServicio());
setShowForm(false);
};
const [finalizando, setFinalizando] = useState(null); // id del servicio a finalizar
const [propinaInput, setPropinaInput] = useState("");
const [descuentoFidelidad, setDescuentoFidelidad] = useState(null); // {programaId, tipo: '50'|'gratis'}
const [reciboId, setReciboId] = useState(null);
const calificar = (s, estrellas) => save({ ...data, servicios: data.servicios.map((x) => (x.id === s.id ? { ...x, calificacion: estrellas } : x)) });
const finalizarServicio = (s) => {
const propina = Number(propinaInput) || 0;
const programas = data.fidelidad && data.fidelidad.length ? data.fidelidad : DEFAULT_FIDELIDAD;
const esMembresia = descuentoFidelidad?.origen === "membresia";
const esFidelidad = descuentoFidelidad?.origen === "fidelidad";
const precioFinal = esMembresia
? 0
: esFidelidad
? (descuentoFidelidad.tipo === "gratis" ? 0 : Math.round(Number(s.precio) / 2))
: s.precio;
const clientesActualizados = data.clientes.map((c) => {
if (c.nombre.trim().toLowerCase() !== s.cliente.trim().toLowerCase()) return c;
const progreso = { ...(c.progreso || {}) };
programas.forEach((p, i) => {
progreso[p.id] = esFidelidad && descuentoFidelidad.programaId === p.id ? 0 : getSellos(c, p, i) + 1;
});
let membresia = c.membresia;
if (esMembresia && membresia) membresia = { ...membresia, lavadosUsados: (Number(membresia.lavadosUsados) || 0) + 1 };
return { ...c, progreso, membresia };
});
save({
...data,
servicios: data.servicios.map((x) =>
x.id === s.id
? {
...x,
estado: "finalizado",
horaFin: Date.now(),
propina,
precio: precioFinal,
premioAplicado: esMembresia
? "Cubierto por membresía"
: esFidelidad
? programas.find((p) => p.id === descuentoFidelidad.programaId)?.nombre || null
: null,
}
: x
),
clientes: clientesActualizados,
});
setFinalizando(null);
setPropinaInput("");
setDescuentoFidelidad(null);
};
const togglePago = (s) => save({ ...data, servicios: data.servicios.map((x) => (x.id === s.id ? { ...x, pagado: !x.pagado } : x)) });
const eliminar = (id) => save({ ...data, servicios: data.servicios.filter((s) => s.id !== id) });
const [editandoTiempo, setEditandoTiempo] = useState(null); // id del servicio
const [inicioInput, setInicioInput] = useState("");
const [finInput, setFinInput] = useState("");
const abrirEditarTiempo = (s) => {
setEditandoTiempo(s.id);
setInicioInput(tsToInputLocal(s.horaInicio || Date.now()));
setFinInput(tsToInputLocal(s.horaFin || Date.now()));
};
const guardarTiempo = (s) => {
const nuevoInicio = inputLocalToTs(inicioInput);
const nuevoFin = s.estado === "finalizado" ? inputLocalToTs(finInput) : s.horaFin;
save({ ...data, servicios: data.servicios.map((x) => (x.id === s.id ? { ...x, horaInicio: nuevoInicio, horaFin: nuevoFin } : x)) });
setEditandoTiempo(null);
};
const porFecha = useMemo(() => {
const groups = {};
[...misServicios]
.sort((a, b) => (a.fecha < b.fecha ? 1 : -1))
.forEach((s) => {
groups[s.fecha] = groups[s.fecha] || [];
groups[s.fecha].push(s);
});
return groups;
}, [misServicios]);
const miDia = useMemo(() => {
if (!miNombre) return null;
const hoy = todayStr();
const propios = misServicios.filter((s) => s.fecha === hoy && s.empleado === miNombre);
const generado = propios.reduce((a, s) => a + Number(s.precio), 0);
const propinas = propios.reduce((a, s) => a + Number(s.propina || 0), 0);
const finalizados = propios.filter((s) => s.estado === "finalizado").length;
const enCurso = propios.length - finalizados;
const misEmp = data.empleados.find((e) => e.nombre === miNombre);
const comisionGanada = generado * ((Number(misEmp?.comision) || 0) / 100);
return { total: propios.length, finalizados, enCurso, generado, propinas, comisionGanada, comision: Number(misEmp?.comision) || 0 };
}, [misServicios, miNombre, data.empleados]);
if (reciboId) {
const servicioRecibo = data.servicios.find((s) => s.id === reciboId);
if (servicioRecibo) {
return setReciboId(null)} />;
}
}
return (
{miDia && (
Tu día de hoy
{miDia.total}
servicios{miDia.enCurso > 0 ? ` (${miDia.enCurso} en curso)` : ""}
{money(miDia.generado)}
generado
{money(miDia.propinas)}
propinas
{miDia.comision > 0 && (
{money(miDia.comisionGanada)}
tu comisión ({miDia.comision}%)
)}
)}
{!showForm ? (
{ setForm(blankServicio()); setShowForm(true); }}
style={{ background: ACCENT, color: BG }}
className="rounded-xl py-2.5 font-semibold text-sm flex items-center justify-center gap-1.5"
>
Registrar servicio
) : (
{form.id ? "Editar servicio" : "Nuevo servicio"}
setForm({ ...form, fecha: v })} />
Cliente
{
const val = e.target.value;
const match = data.clientes.find((c) => c.nombre.trim().toLowerCase() === val.trim().toLowerCase());
setForm({
...form,
cliente: val,
placas: match ? match.placas || form.placas : form.placas,
});
}}
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((c) => c.nombre.trim().toLowerCase() === form.cliente.trim().toLowerCase());
return match?.notas ? (
{match.notas}
) : null;
})()}
setForm({ ...form, placas: v })} placeholder="Ej. ABC-123" />
Tipo de vehículo
setForm({ ...form, tipoVehiculo: e.target.value })}
className="w-full rounded-lg px-3 py-2 text-sm outline-none mt-1"
>
{TIPOS_VEHICULO.map((v) => {v} )}
Tipo de servicio
{
const tipos = data.tiposServicio && data.tiposServicio.length ? data.tiposServicio : DEFAULT_TIPOS_SERVICIO;
const t = tipos.find((x) => x.nombre === e.target.value);
const extrasTotal = (form.extras || []).reduce((a, ex) => a + Number(ex.precio), 0);
setForm({ ...form, tipo: e.target.value, precio: t ? t.precio + extrasTotal : form.precio });
}}
className="w-full rounded-lg px-3 py-2 text-sm outline-none mt-1"
>
{(data.tiposServicio && data.tiposServicio.length ? data.tiposServicio : DEFAULT_TIPOS_SERVICIO).map((t) => (
{t.nombre}
))}
Otro
Servicios adicionales (opcional)
{(data.serviciosAdicionales && data.serviciosAdicionales.length ? data.serviciosAdicionales : DEFAULT_ADICIONALES).map((extra) => {
const activo = (form.extras || []).some((e) => e.nombre === extra.nombre);
return (
toggleExtra(extra)}
className="text-xs px-2.5 py-1.5 rounded-lg font-medium flex items-center gap-1"
style={{ background: activo ? ACCENT : SURFACE2, color: activo ? BG : TEXT }}
>
{activo ? : } {extra.nombre} · {money(extra.precio)}
);
})}
setForm({ ...form, precio: v })} />
{misEmpleados.length > 0 && (
Empleado
setForm({ ...form, empleado: e.target.value })}
className="w-full rounded-lg px-3 py-2 text-sm outline-none mt-1"
>
Sin asignar
{misEmpleados.map((e) => {e.nombre} )}
)}
Método de pago
{METODOS_PAGO.map((m) => (
setForm({ ...form, metodo: m.id })}
style={{
background: form.metodo === m.id ? ACCENT : SURFACE2,
color: form.metodo === m.id ? BG : TEXT,
}}
className="flex-1 rounded-lg py-2 text-xs flex flex-col items-center gap-1"
>
{m.label}
))}
setForm({ ...form, pagado: e.target.checked })} />
Ya está pagado
setShowForm(false)} onSave={guardar} />
)}
{Object.keys(porFecha).length === 0 && !showForm && (
Sin servicios registrados todavía.
)}
{Object.entries(porFecha).map(([f, list]) => (
{fecha(f)}
{list.map((s) => {
const enProgreso = s.estado !== "finalizado";
return (
{s.cliente}{s.placas ? · {s.placas} : ""}
{s.tipoVehiculo ? `${s.tipoVehiculo} · ` : ""}{s.tipo}{s.empleado ? ` · ${s.empleado}` : ""}
{s.extras && s.extras.length > 0 && (
+ {s.extras.map((e) => e.nombre).join(", ")}
)}
{!enProgreso &&
{money(s.precio)}
}
{enProgreso ? (
{s.horaInicio ? : "En progreso"}
) : (
togglePago(s)}
style={{ background: s.pagado ? "#1E4A3A" : "#4A331E", color: s.pagado ? "#6FE0A8" : GOLD }}
className="text-[10px] px-2 py-1 rounded-full font-medium"
>
{s.pagado ? "Pagado" : "Pendiente"}
)}
{enProgreso && (
abrirEditarTiempo(s)}>
)}
abrirEditarServicio(s)}>
eliminar(s.id)} danger>
{editandoTiempo === s.id && (
Ajustar hora {s.estado === "finalizado" ? "de inicio y fin" : "de inicio"}
{s.estado === "finalizado" && (
)}
setEditandoTiempo(null)} onSave={() => guardarTiempo(s)} />
)}
{enProgreso && finalizando !== s.id && (
{ setFinalizando(s.id); setPropinaInput(""); }}
style={{ background: ACCENT, color: BG }}
className="w-full mt-3 rounded-lg py-2 text-xs font-semibold flex items-center justify-center gap-1.5"
>
Finalizar servicio
)}
{finalizando === s.id && (
{(() => {
const cliente = data.clientes.find((c) => c.nombre.trim().toLowerCase() === s.cliente.trim().toLowerCase());
if (!cliente) return null;
const programas = data.fidelidad && data.fidelidad.length ? data.fidelidad : DEFAULT_FIDELIDAD;
const elegibles = programas
.map((p, i) => ({ p, i, proyectado: getSellos(cliente, p, i) + 1 }))
.filter((x) => x.proyectado >= (Number(x.p.metaSellos) || 1));
const planes = data.membresias || [];
const m = cliente.membresia;
const planCliente = m ? planes.find((p) => p.id === m.planId) : null;
const membresiaValida =
m && planCliente && m.fechaFin >= todayStr() &&
(!planCliente.lavadosIncluidos || m.lavadosUsados < planCliente.lavadosIncluidos);
if (elegibles.length === 0 && !membresiaValida) return null;
return (
Beneficios disponibles
setDescuentoFidelidad(null)}
className="text-left text-xs px-2 py-1.5 rounded-md"
style={{ background: !descuentoFidelidad ? ACCENT : SURFACE2, color: !descuentoFidelidad ? BG : TEXT }}
>
No aplicar
{membresiaValida && (
setDescuentoFidelidad({ origen: "membresia" })}
className="text-left text-xs px-2 py-1.5 rounded-md flex items-center gap-1.5"
style={{ background: descuentoFidelidad?.origen === "membresia" ? ACCENT : SURFACE2, color: descuentoFidelidad?.origen === "membresia" ? BG : TEXT }}
>
Cubierto por membresía: {planCliente.nombre}
)}
{elegibles.map(({ p }) => {
const esGratis = /gratis/i.test(p.premio);
const tipo = esGratis ? "gratis" : "50";
const activo = descuentoFidelidad?.origen === "fidelidad" && descuentoFidelidad?.programaId === p.id;
return (
setDescuentoFidelidad({ origen: "fidelidad", programaId: p.id, tipo })}
className="text-left text-xs px-2 py-1.5 rounded-md"
style={{ background: activo ? ACCENT : SURFACE2, color: activo ? BG : TEXT }}
>
Aplicar {p.nombre} · {p.premio}
);
})}
);
})()}
{ setFinalizando(null); setDescuentoFidelidad(null); }} onSave={() => finalizarServicio(s)} />
)}
{!enProgreso && (
<>
{s.horaInicio && s.horaFin ? `Duró ${duracionTexto(s.horaFin - s.horaInicio)}` : "Sin tiempo registrado"}
{s.propina > 0 ? ` · Propina ${money(s.propina)}` : ""}
{s.premioAplicado ? (
· 🎁 {s.premioAplicado}
) : ""}
abrirEditarTiempo(s)} className="underline">editar tiempo
{[1, 2, 3, 4, 5].map((n) => (
calificar(s, n)}>
))}
setReciboId(s.id)}
className="text-xs font-medium flex items-center gap-1"
style={{ color: ACCENT }}
>
Recibo
>
)}
);
})}
))}
);
}
/* ---------------- Recibo digital ---------------- */
function ReciboServicio({ servicio: s, data, onBack }) {
const cliente = data.clientes.find((c) => c.nombre.trim().toLowerCase() === s.cliente.trim().toLowerCase());
const fechaHora = new Date(s.horaFin || Date.now()).toLocaleString("es-MX", {
day: "numeric", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit",
});
const metodoLabel = METODOS_PAGO.find((m) => m.id === s.metodo)?.label || s.metodo;
const extras = s.extras || [];
const extrasTotal = extras.reduce((a, e) => a + Number(e.precio), 0);
const precioBase = Math.max(0, Number(s.precio) - extrasTotal);
const total = Number(s.precio) + Number(s.propina || 0);
const textoWhatsapp = () => {
let t = `🧾 *Recibo · ${data.nombreNegocio}*\n📅 ${fechaHora}\n\n`;
t += `Cliente: ${s.cliente}\n`;
if (s.placas) t += `Placas: ${s.placas}\n`;
t += `Vehículo: ${s.tipoVehiculo}\n`;
t += `Servicio: ${s.tipo} (${money(precioBase)})\n`;
extras.forEach((e) => { t += `+ ${e.nombre}: ${money(e.precio)}\n`; });
if (s.empleado) t += `Atendido por: ${s.empleado}\n`;
t += `\nPrecio: ${money(s.precio)}\n`;
if (s.premioAplicado) t += `Premio aplicado: ${s.premioAplicado}\n`;
if (s.propina > 0) t += `Propina: ${money(s.propina)}\n`;
t += `*Total: ${money(total)}*\n`;
t += `Método de pago: ${metodoLabel} · ${s.pagado ? "Pagado" : "Pendiente"}\n`;
t += `\n¡Gracias por tu preferencia! 🚗💦`;
return t;
};
const compartirWhatsapp = () => {
const tel = cliente?.telefono ? cliente.telefono.replace(/\D/g, "") : "";
window.open(`https://wa.me/${tel}?text=${encodeURIComponent(textoWhatsapp())}`, "_blank");
};
return (
Regresar
{data.nombreNegocio}
{fechaHora}
Cliente
{s.cliente}
{s.placas && (
Placas
{s.placas}
)}
Vehículo
{s.tipoVehiculo}
Servicio
{s.tipo}
{s.empleado && (
Atendido por
{s.empleado}
)}
{s.tipo}
{money(precioBase)}
{extras.map((e) => (
+ {e.nombre}
{money(e.precio)}
))}
{s.premioAplicado && (
🎁 Premio aplicado
{s.premioAplicado}
)}
{s.propina > 0 && (
Propina
{money(s.propina)}
)}
Total
{money(total)}
{metodoLabel} · {s.pagado ? "Pagado" : "Pendiente de pago"}
WhatsApp
window.print()}
className="flex-1 rounded-xl py-2.5 text-sm font-semibold flex items-center justify-center gap-1.5"
style={{ background: SURFACE2, color: TEXT }}
>
Imprimir
);
}
/* ---------------- Empleados ---------------- */
function Empleados({ data, save, sucursalId, sucursales }) {
const [form, setForm] = useState(null);
const listaSucursales = sucursales && sucursales.length ? sucursales : DEFAULT_SUCURSALES;
const misEmpleados = data.empleados.filter((e) => pertenece(e, sucursalId, listaSucursales));
const guardar = () => {
if (!form.nombre.trim()) return;
let empleados;
if (form.id) empleados = data.empleados.map((e) => (e.id === form.id ? form : e));
else empleados = [...data.empleados, { ...form, id: uid(), sucursalId }];
save({ ...data, empleados });
setForm(null);
};
const eliminar = (id) => save({ ...data, empleados: data.empleados.filter((e) => e.id !== id) });
const statsFor = (nombre, comision) => {
const propios = data.servicios.filter((s) => s.empleado === nombre);
const generado = propios.reduce((a, s) => a + Number(s.precio), 0);
const calificados = propios.filter((s) => Number(s.calificacion) > 0);
const promedioCalificacion = calificados.length
? calificados.reduce((a, s) => a + Number(s.calificacion), 0) / calificados.length
: 0;
return {
servicios: propios.length,
generado,
propinas: propios.reduce((a, s) => a + Number(s.propina || 0), 0),
comisionGanada: generado * ((Number(comision) || 0) / 100),
promedioCalificacion,
totalCalificados: calificados.length,
};
};
return (
{!form ? (
setForm({ id: null, nombre: "", puesto: "Lavador", telefono: "", pin: "", comision: "" })}
style={{ background: ACCENT, color: BG }}
className="rounded-xl py-2.5 font-semibold text-sm flex items-center justify-center gap-1.5"
>
Agregar empleado
) : (
setForm({ ...form, nombre: v })} />
setForm({ ...form, puesto: v })} placeholder="Ej. Lavador, encargado…" />
setForm({ ...form, telefono: v })} />
{listaSucursales.length > 1 && (
Sucursal
setForm({ ...form, sucursalId: e.target.value })}
className="w-full rounded-lg px-3 py-2 text-sm outline-none mt-1"
>
{listaSucursales.map((s) => {s.nombre} )}
)}
setForm({ ...form, comision: v })}
placeholder="Ej. 20"
/>
setForm({ ...form, pin: v.replace(/\D/g, "").slice(0, 4) })}
placeholder="Ej. 1234"
/>
setForm(null)} onSave={guardar} />
)}
{misEmpleados.length === 0 && !form && (
Aún no registras a tu equipo.
)}
{misEmpleados.map((e) => {
const st = statsFor(e.nombre, e.comision);
return (
{e.nombre}
{e.puesto}{e.pin ? " · PIN protegido" : ""}{Number(e.comision) > 0 ? ` · ${e.comision}% de comisión` : ""}
{st.servicios} servicios · {money(st.generado)}
{st.propinas > 0 ? ` · ${money(st.propinas)} en propinas` : ""}
{st.totalCalificados > 0 && (
{st.promedioCalificacion.toFixed(1)} ({st.totalCalificados} calificaciones)
)}
{Number(e.comision) > 0 && (
{money(st.comisionGanada)} en comisión acumulada
)}
setForm(e)}>
eliminar(e.id)} danger>
);
})}
);
}
/* ---------------- Finanzas (ingresos + gastos) ---------------- */
function Finanzas({ data, save, sucursalId }) {
const [vista, setVista] = useState("ingresos");
const [periodo, setPeriodo] = useState("hoy");
const [mostrarReporte, setMostrarReporte] = useState(false);
const hoy = todayStr();
const sucursales = data.sucursales && data.sucursales.length ? data.sucursales : DEFAULT_SUCURSALES;
const enRango = (f) => {
if (periodo === "hoy") return f === hoy;
if (periodo === "semana") {
const d = new Date(f), now = new Date();
const diff = (now - d) / 86400000;
return diff >= 0 && diff < 7;
}
if (periodo === "mes") return f.slice(0, 7) === hoy.slice(0, 7);
return true;
};
const servFiltrados = data.servicios.filter((s) => pertenece(s, sucursalId, sucursales) && enRango(s.fecha));
const totalIngreso = servFiltrados.reduce((a, s) => a + Number(s.precio), 0);
const gastosFiltrados = (data.gastos || []).filter((g) => pertenece(g, sucursalId, sucursales) && enRango(g.fecha));
const totalGastos = gastosFiltrados.reduce((a, g) => a + Number(g.monto), 0);
const neto = totalIngreso - totalGastos;
const efectivoIngreso = servFiltrados
.filter((s) => s.pagado && (s.metodo || "efectivo") === "efectivo")
.reduce((a, s) => a + Number(s.precio), 0);
const efectivoGasto = gastosFiltrados
.filter((g) => (g.metodo || "efectivo") === "efectivo")
.reduce((a, g) => a + Number(g.monto), 0);
const efectivoCaja = efectivoIngreso - efectivoGasto;
const otrosMetodos = totalIngreso - servFiltrados.filter((s) => (s.metodo || "efectivo") === "efectivo").reduce((a, s) => a + Number(s.precio), 0);
const misEmpleados = (data.empleados || []).filter((e) => pertenece(e, sucursalId, sucursales));
const comisionesTotal = misEmpleados.reduce((a, e) => {
const generado = servFiltrados.filter((s) => s.empleado === e.nombre).reduce((x, s) => x + Number(s.precio), 0);
return a + generado * ((Number(e.comision) || 0) / 100);
}, 0);
const utilidadReal = neto - comisionesTotal;
const misCarteras = (data.carteras && data.carteras.length ? data.carteras : DEFAULT_CARTERAS).filter((c) => pertenece(c, sucursalId, sucursales));
const totalApartado = misCarteras.reduce((a, c) => a + Number(c.saldo || 0), 0);
const disponibleReal = efectivoCaja - totalApartado;
const periodoSelector = (
{[["hoy", "Hoy"], ["semana", "Semana"], ["mes", "Mes"], ["todo", "Todo"]].map(([id, label]) => (
setPeriodo(id)}
style={{ background: periodo === id ? ACCENT : SURFACE2, color: periodo === id ? BG : TEXT }}
className="flex-1 rounded-lg py-2 text-xs font-medium"
>
{label}
))}
);
if (mostrarReporte) {
return (
setMostrarReporte(false)}
/>
);
}
return (
{periodoSelector}
setMostrarReporte(true)}
className="w-full rounded-xl py-2.5 text-sm font-semibold flex items-center justify-center gap-1.5"
style={{ background: SURFACE2, color: ACCENT }}
>
Ver reporte para compartir
Ganancia neta (ingresos − gastos)
= 0 ? "#6FE0A8" : DANGER }}>
{money(neto)}
● Ingresos {money(totalIngreso)}
● Gastos {money(totalGastos)}
{comisionesTotal > 0 && (
Utilidad real (después de comisiones a lavadores)
= 0 ? ACCENT : DANGER }}>
{money(utilidadReal)}
{money(neto)} de ganancia neta − {money(comisionesTotal)} en comisiones pendientes de pagar
)}
Efectivo en caja (lo que debe haber físicamente)
{money(efectivoCaja)}
{money(efectivoIngreso)} cobrado en efectivo − {money(efectivoGasto)} en gastos pagados en efectivo
{otrosMetodos > 0 && (
{money(otrosMetodos)} más en transferencia/tarjeta — ese dinero está en el banco, no en la caja.
)}
{totalApartado > 0 && (
periodo === "todo" ? (
Apartado en carteras
− {money(totalApartado)}
Disponible real
= 0 ? ACCENT : DANGER }}>{money(disponibleReal)}
) : (
Tienes {money(totalApartado)} apartado en carteras (ve "Todo" para ver tu disponible real)
)
)}
setVista("ingresos")}
style={{ background: vista === "ingresos" ? ACCENT : "transparent", color: vista === "ingresos" ? BG : TEXT }}
className="flex-1 rounded-lg py-2 text-xs font-semibold flex items-center justify-center gap-1"
>
Ingresos
setVista("gastos")}
style={{ background: vista === "gastos" ? ACCENT : "transparent", color: vista === "gastos" ? BG : TEXT }}
className="flex-1 rounded-lg py-2 text-xs font-semibold flex items-center justify-center gap-1"
>
Gastos
setVista("comisiones")}
style={{ background: vista === "comisiones" ? ACCENT : "transparent", color: vista === "comisiones" ? BG : TEXT }}
className="flex-1 rounded-lg py-2 text-xs font-semibold flex items-center justify-center gap-1"
>
Comisiones
setVista("utilidad")}
style={{ background: vista === "utilidad" ? ACCENT : "transparent", color: vista === "utilidad" ? BG : TEXT }}
className="flex-1 rounded-lg py-2 text-xs font-semibold flex items-center justify-center gap-1"
>
Utilidad
setVista("carteras")}
style={{ background: vista === "carteras" ? ACCENT : "transparent", color: vista === "carteras" ? BG : TEXT }}
className="flex-1 rounded-lg py-2 text-xs font-semibold flex items-center justify-center gap-1 shrink-0"
>
Carteras
{vista === "ingresos" &&
}
{vista === "gastos" &&
}
{vista === "comisiones" &&
}
{vista === "utilidad" && (
)}
{vista === "carteras" &&
}
);
}
function VistaComisiones({ data, filtrados, sucursalId }) {
const sucursales = data.sucursales && data.sucursales.length ? data.sucursales : DEFAULT_SUCURSALES;
const misEmpleados = (data.empleados || []).filter((e) => pertenece(e, sucursalId, sucursales));
const conComision = misEmpleados.filter((e) => Number(e.comision) > 0);
const filas = misEmpleados.map((e) => {
const propios = filtrados.filter((s) => s.empleado === e.nombre);
const generado = propios.reduce((a, s) => a + Number(s.precio), 0);
const comisionGanada = generado * ((Number(e.comision) || 0) / 100);
return { empleado: e, servicios: propios.length, generado, comisionGanada };
});
const totalAPagar = filas.reduce((a, f) => a + f.comisionGanada, 0);
return (
{conComision.length === 0 ? (
Ningún empleado tiene un % de comisión configurado. Ponlo desde Equipo para ver aquí cuánto les toca pagar.
) : (
<>
Total a pagar en comisiones
{money(totalAPagar)}
{filas.filter((f) => Number(f.empleado.comision) > 0).map((f) => (
{f.empleado.nombre}
{f.servicios} servicios · {money(f.generado)} generado · {f.empleado.comision}%
{money(f.comisionGanada)}
))}
>
)}
);
}
function VistaUtilidad({ totalIngreso, totalGastos, gastosFiltrados, comisionesTotal, neto, utilidadReal }) {
const porCategoria = useMemo(() => {
const g = {};
gastosFiltrados.forEach((x) => { g[x.categoria] = (g[x.categoria] || 0) + Number(x.monto); });
return Object.entries(g).sort((a, b) => b[1] - a[1]);
}, [gastosFiltrados]);
const sueldosFijos = porCategoria.find(([cat]) => cat === "Sueldos")?.[1] || 0;
const insumos = porCategoria.find(([cat]) => cat === "Insumos y jabones")?.[1] || 0;
const otrosGastos = totalGastos - sueldosFijos - insumos;
const Fila = ({ label, monto, resta, destacado, color }) => (
{label}
{resta ? "− " : ""}{money(monto)}
);
return (
De tus ingresos a tu utilidad real, paso a paso
= 0 ? "#6FE0A8" : DANGER} />
"Ganancia neta" ({money(neto)}) solo resta gastos. "Utilidad real" también descuenta lo que les debes
a tus lavadores por comisión — así sabes lo que de verdad te queda a ti.
);
}
/* ---------------- Carteras (dinero apartado) ---------------- */
function VistaCarteras({ data, save, sucursalId }) {
const sucursales = data.sucursales && data.sucursales.length ? data.sucursales : DEFAULT_SUCURSALES;
const carteras = (data.carteras && data.carteras.length ? data.carteras : DEFAULT_CARTERAS).filter((c) => pertenece(c, sucursalId, sucursales));
const [formNueva, setFormNueva] = useState(null); // {nombre}
const [accion, setAccion] = useState(null); // {carteraId, tipo: 'apartar'|'usar', monto, nota, categoria}
const totalApartado = carteras.reduce((a, c) => a + Number(c.saldo || 0), 0);
const crearCartera = () => {
if (!formNueva.nombre.trim()) return;
const todas = data.carteras && data.carteras.length ? data.carteras : DEFAULT_CARTERAS;
save({ ...data, carteras: [...todas, { id: uid(), nombre: formNueva.nombre.trim(), saldo: 0, sucursalId }] });
setFormNueva(null);
};
const eliminarCartera = (id) => {
const todas = data.carteras && data.carteras.length ? data.carteras : DEFAULT_CARTERAS;
save({ ...data, carteras: todas.filter((c) => c.id !== id) });
};
const abrirApartar = (c) => setAccion({ carteraId: c.id, tipo: "apartar", monto: "", nota: "" });
const abrirUsar = (c) => setAccion({ carteraId: c.id, tipo: "usar", monto: "", nota: "", categoria: "Insumos y jabones" });
const confirmarAccion = () => {
const monto = Number(accion.monto);
if (!monto || monto <= 0) return;
const todas = data.carteras && data.carteras.length ? data.carteras : DEFAULT_CARTERAS;
const cartera = todas.find((c) => c.id === accion.carteraId);
if (!cartera) return;
if (accion.tipo === "apartar") {
const carterasActualizadas = todas.map((c) => (c.id === cartera.id ? { ...c, saldo: Number(c.saldo || 0) + monto } : c));
const movimiento = { id: uid(), carteraId: cartera.id, tipo: "aparte", monto, fecha: todayStr(), nota: accion.nota };
save({ ...data, carteras: carterasActualizadas, movimientosCartera: [movimiento, ...(data.movimientosCartera || [])] });
} else {
const montoUsado = Math.min(monto, Number(cartera.saldo || 0));
const carterasActualizadas = todas.map((c) => (c.id === cartera.id ? { ...c, saldo: Math.max(0, Number(c.saldo || 0) - monto) } : c));
const nuevoGasto = {
id: uid(),
fecha: todayStr(),
concepto: accion.nota.trim() || `Compra con dinero apartado: ${cartera.nombre}`,
categoria: accion.categoria || "Insumos y jabones",
monto: montoUsado,
metodo: "efectivo",
sucursalId,
};
const movimiento = { id: uid(), carteraId: cartera.id, tipo: "uso", monto: montoUsado, fecha: todayStr(), nota: accion.nota };
save({
...data,
carteras: carterasActualizadas,
gastos: [nuevoGasto, ...(data.gastos || [])],
movimientosCartera: [movimiento, ...(data.movimientosCartera || [])],
});
}
setAccion(null);
};
return (
Total apartado
{money(totalApartado)}
Este dinero sigue físicamente en tu caja, pero ya está reservado — no cuenta como disponible.
{!formNueva ? (
setFormNueva({ nombre: "" })}
style={{ background: SURFACE2, color: ACCENT }}
className="rounded-xl py-2.5 font-semibold text-sm flex items-center justify-center gap-1.5"
>
Nueva cartera
) : (
setFormNueva({ nombre: v })} placeholder="Ej. Insumos y productos" />
setFormNueva(null)} onSave={crearCartera} />
)}
{carteras.length === 0 && !formNueva && (
Aún no tienes carteras. Crea una para empezar a apartar dinero, por ejemplo "Insumos y productos".
)}
{carteras.map((c) => (
{c.nombre}
{money(c.saldo || 0)}
eliminarCartera(c.id)} danger>
{accion?.carteraId === c.id ? (
{accion.tipo === "apartar" ? "Apartar dinero en esta cartera" : "Usar dinero de esta cartera"}
setAccion({ ...accion, monto: v })}
placeholder={accion.tipo === "usar" ? `Máximo ${money(c.saldo || 0)}` : ""}
/>
{accion.tipo === "usar" && (
Categoría del gasto
setAccion({ ...accion, categoria: e.target.value })}
className="w-full rounded-lg px-3 py-2 text-sm outline-none mt-1"
>
{CATEGORIAS_GASTO.map((cat) => {cat} )}
)}
setAccion({ ...accion, nota: v })}
placeholder={accion.tipo === "usar" ? "Ej. Jabón y cera" : ""}
/>
setAccion(null)} onSave={confirmarAccion} />
) : (
abrirApartar(c)}
className="flex-1 rounded-lg py-2 text-xs font-medium flex items-center justify-center gap-1"
style={{ background: SURFACE2, color: ACCENT }}
>
Apartar
abrirUsar(c)}
disabled={!c.saldo}
className="flex-1 rounded-lg py-2 text-xs font-medium flex items-center justify-center gap-1"
style={{ background: SURFACE2, color: c.saldo ? GOLD : MUTED, opacity: c.saldo ? 1 : 0.5 }}
>
Usar
)}
))}
);
}
/* ---------------- Reporte compartible ---------------- */
const PERIODO_LABEL = { hoy: "Hoy", semana: "Últimos 7 días", mes: "Este mes", todo: "Historial completo" };
function ReporteFinanciero({ data, periodo, servFiltrados, gastosFiltrados, totalIngreso, totalGastos, neto, sucursalId, onBack }) {
const propinas = servFiltrados.reduce((a, s) => a + Number(s.propina || 0), 0);
const cobrado = servFiltrados.filter((s) => s.pagado).reduce((a, s) => a + Number(s.precio), 0);
const pendiente = totalIngreso - cobrado;
const sucursales = data.sucursales && data.sucursales.length ? data.sucursales : DEFAULT_SUCURSALES;
const nombreSucursal = sucursales.find((s) => s.id === sucursalId)?.nombre;
const efectivoIngreso = servFiltrados
.filter((s) => s.pagado && (s.metodo || "efectivo") === "efectivo")
.reduce((a, s) => a + Number(s.precio), 0);
const efectivoGasto = gastosFiltrados
.filter((g) => (g.metodo || "efectivo") === "efectivo")
.reduce((a, g) => a + Number(g.monto), 0);
const efectivoCaja = efectivoIngreso - efectivoGasto;
const transferenciaTarjeta = totalIngreso - servFiltrados.filter((s) => (s.metodo || "efectivo") === "efectivo").reduce((a, s) => a + Number(s.precio), 0);
const porTipo = useMemo(() => {
const g = {};
servFiltrados.forEach((s) => { g[s.tipo] = (g[s.tipo] || 0) + Number(s.precio); });
return Object.entries(g).sort((a, b) => b[1] - a[1]);
}, [servFiltrados]);
const porCategoriaGasto = useMemo(() => {
const g = {};
gastosFiltrados.forEach((x) => { g[x.categoria] = (g[x.categoria] || 0) + Number(x.monto); });
return Object.entries(g).sort((a, b) => b[1] - a[1]);
}, [gastosFiltrados]);
const comisiones = (data.empleados || [])
.filter((e) => pertenece(e, sucursalId, sucursales) && Number(e.comision) > 0)
.map((e) => {
const generado = servFiltrados.filter((s) => s.empleado === e.nombre).reduce((a, s) => a + Number(s.precio), 0);
return { nombre: e.nombre, monto: generado * (Number(e.comision) / 100) };
})
.filter((c) => c.monto > 0);
const comisionesTotal = comisiones.reduce((a, c) => a + c.monto, 0);
const utilidadReal = neto - comisionesTotal;
const fechaHoy = new Date().toLocaleDateString("es-MX", { day: "numeric", month: "long", year: "numeric" });
const textoWhatsapp = () => {
let t = `📊 *Reporte ${data.nombreNegocio}*\n🗓️ ${PERIODO_LABEL[periodo]} (generado ${fechaHoy})\n\n`;
t += `💰 Ingresos: ${money(totalIngreso)}\n`;
t += `💸 Gastos: ${money(totalGastos)}\n`;
t += `📈 Ganancia neta: ${money(neto)}\n`;
if (comisionesTotal > 0) t += `📉 Utilidad real (tras comisiones): ${money(utilidadReal)}\n`;
t += `\n💵 Efectivo en caja: ${money(efectivoCaja)}\n`;
if (transferenciaTarjeta > 0) t += `🏦 Transferencia/tarjeta: ${money(transferenciaTarjeta)} (no está en caja)\n`;
t += `🎁 Propinas: ${money(propinas)}\n`;
t += `🚗 Servicios realizados: ${servFiltrados.length}\n`;
if (pendiente > 0) t += `⏳ Por cobrar: ${money(pendiente)}\n`;
if (porTipo.length > 0) {
t += `\n*Por tipo de servicio:*\n`;
porTipo.forEach(([tipo, monto]) => { t += `• ${tipo}: ${money(monto)}\n`; });
}
if (comisiones.length > 0) {
t += `\n*Comisiones a pagar:*\n`;
comisiones.forEach((c) => { t += `• ${c.nombre}: ${money(c.monto)}\n`; });
}
return t;
};
const compartirWhatsapp = () => {
window.open(`https://wa.me/?text=${encodeURIComponent(textoWhatsapp())}`, "_blank");
};
return (
Regresar
{data.nombreNegocio}
{nombreSucursal ? `${nombreSucursal} · ` : ""}{PERIODO_LABEL[periodo]} · generado el {fechaHoy}
Ganancia neta
= 0 ? "#6FE0A8" : DANGER }}>{money(neto)}
● Ingresos: {money(totalIngreso)}
● Gastos: {money(totalGastos)}
● Propinas: {money(propinas)}
● Servicios: {servFiltrados.length}
{pendiente > 0 && (
Aún por cobrar: {money(pendiente)}
)}
{comisionesTotal > 0 && (
Utilidad real (después de comisiones)
= 0 ? ACCENT : DANGER }}>{money(utilidadReal)}
{money(neto)} de ganancia neta − {money(comisionesTotal)} en comisiones a lavadores
)}
Efectivo en caja
{money(efectivoCaja)}
{transferenciaTarjeta > 0 && (
+ {money(transferenciaTarjeta)} en transferencia/tarjeta (ese dinero está en el banco, no en caja)
)}
{porTipo.length > 0 && (
Ingresos por tipo de servicio
{porTipo.map(([tipo, monto]) => (
{tipo}
{money(monto)}
))}
)}
{porCategoriaGasto.length > 0 && (
Gastos por categoría
{porCategoriaGasto.map(([cat, monto]) => (
{cat}
-{money(monto)}
))}
)}
{comisiones.length > 0 && (
Comisiones a pagar
{comisiones.map((c) => (
{c.nombre}
{money(c.monto)}
))}
)}
WhatsApp
window.print()}
className="flex-1 rounded-xl py-2.5 text-sm font-semibold flex items-center justify-center gap-1.5"
style={{ background: SURFACE2, color: TEXT }}
>
Imprimir
);
}
function VistaIngresos({ filtrados }) {
const total = filtrados.reduce((a, s) => a + Number(s.precio), 0);
const cobrado = filtrados.filter((s) => s.pagado).reduce((a, s) => a + Number(s.precio), 0);
const pendiente = total - cobrado;
const propinas = filtrados.reduce((a, s) => a + Number(s.propina || 0), 0);
const porTipo = useMemo(() => {
const g = {};
filtrados.forEach((s) => { g[s.tipo] = (g[s.tipo] || 0) + Number(s.precio); });
return Object.entries(g).sort((a, b) => b[1] - a[1]);
}, [filtrados]);
const porMetodo = useMemo(() => {
const g = {};
filtrados.forEach((s) => { g[s.metodo || "efectivo"] = (g[s.metodo || "efectivo"] || 0) + Number(s.precio); });
return g;
}, [filtrados]);
const maxTipo = Math.max(1, ...porTipo.map((t) => t[1]));
return (
Ingreso total
{money(total)}
● Cobrado {money(cobrado)}
● Pendiente {money(pendiente)}
● Propinas {money(propinas)}
Por tipo de servicio
{porTipo.length === 0 && Sin datos en este periodo.
}
{porTipo.map(([tipo, monto]) => (
))}
Por método de pago
{METODOS_PAGO.map((m) => (
{money(porMetodo[m.id] || 0)}
{m.label}
))}
);
}
function VistaGastos({ data, save, filtrados, periodo, sucursalId }) {
const [form, setForm] = useState(null);
const startNew = () =>
setForm({ id: null, fecha: todayStr(), concepto: "", categoria: CATEGORIAS_GASTO[0], monto: "", metodo: "efectivo" });
const guardar = () => {
if (!form.concepto.trim() || !form.monto) return;
const gastos = data.gastos || [];
let nuevos;
if (form.id) nuevos = gastos.map((g) => (g.id === form.id ? form : g));
else nuevos = [{ ...form, id: uid(), sucursalId }, ...gastos];
save({ ...data, gastos: nuevos });
setForm(null);
};
const eliminar = (id) => save({ ...data, gastos: (data.gastos || []).filter((g) => g.id !== id) });
const porCategoria = useMemo(() => {
const g = {};
filtrados.forEach((x) => { g[x.categoria] = (g[x.categoria] || 0) + Number(x.monto); });
return Object.entries(g).sort((a, b) => b[1] - a[1]);
}, [filtrados]);
const maxCat = Math.max(1, ...porCategoria.map((c) => c[1]));
const ordenados = [...filtrados].sort((a, b) => (a.fecha < b.fecha ? 1 : -1));
return (
{!form ? (
Registrar gasto
) : (
setForm({ ...form, fecha: v })} />
setForm({ ...form, concepto: v })} placeholder="Ej. Jabón y cera" />
Categoría
setForm({ ...form, categoria: e.target.value })}
className="w-full rounded-lg px-3 py-2 text-sm outline-none mt-1"
>
{CATEGORIAS_GASTO.map((c) => {c} )}
setForm({ ...form, monto: v })} />
¿De dónde salió el dinero?
{METODOS_PAGO.map((m) => (
setForm({ ...form, metodo: m.id })}
style={{
background: (form.metodo || "efectivo") === m.id ? DANGER : SURFACE2,
color: (form.metodo || "efectivo") === m.id ? "#FCEAE3" : TEXT,
}}
className="flex-1 rounded-lg py-2 text-xs flex flex-col items-center gap-1"
>
{m.label}
))}
setForm(null)} onSave={guardar} />
)}
{porCategoria.length > 0 && (
Gastos por categoría
{porCategoria.map(([cat, monto]) => (
))}
)}
{ordenados.length === 0 && !form && (
Sin gastos registrados {periodo === "todo" ? "" : "en este periodo"}. Usa el botón de arriba para anotar el primero.
)}
{ordenados.map((g) => (
{g.concepto}
{g.categoria} · {fecha(g.fecha)} · {METODOS_PAGO.find((m) => m.id === (g.metodo || "efectivo"))?.label}
-{money(g.monto)}
setForm(g)}>
eliminar(g.id)} danger>
))}
);
}
/* ---------------- Inventario ---------------- */
function Inventario({ data, save, sucursalId }) {
const [form, setForm] = useState(null);
const [movForm, setMovForm] = useState(null); // { productoId, tipo }
const sucursales = data.sucursales && data.sucursales.length ? data.sucursales : DEFAULT_SUCURSALES;
const inventario = (data.inventario || []).filter((p) => pertenece(p, sucursalId, sucursales));
const idsPropios = new Set(inventario.map((p) => p.id));
const movimientos = (data.movimientosInventario || []).filter((m) => idsPropios.has(m.productoId));
const startNew = () =>
setForm({ id: null, nombre: "", unidad: UNIDADES[0], stock: "", minimo: "", costoUnitario: "" });
const guardarProducto = () => {
if (!form.nombre.trim()) return;
let nuevos;
if (form.id) nuevos = (data.inventario || []).map((p) => (p.id === form.id ? form : p));
else nuevos = [...(data.inventario || []), { ...form, id: uid(), stock: Number(form.stock) || 0, sucursalId }];
save({ ...data, inventario: nuevos });
setForm(null);
};
const eliminarProducto = (id) => {
save({
...data,
inventario: (data.inventario || []).filter((p) => p.id !== id),
movimientosInventario: (data.movimientosInventario || []).filter((m) => m.productoId !== id),
});
};
const abrirMovimiento = (producto, tipo) =>
setMovForm({ productoId: producto.id, tipo, cantidad: "", costoTotal: "", nota: "", fecha: todayStr() });
const guardarMovimiento = () => {
const cantidad = Number(movForm.cantidad);
if (!cantidad || cantidad <= 0) return;
const producto = inventario.find((p) => p.id === movForm.productoId);
if (!producto) return;
const nuevoStock =
movForm.tipo === "compra"
? Number(producto.stock) + cantidad
: Math.max(0, Number(producto.stock) - cantidad);
const inventarioActualizado = (data.inventario || []).map((p) =>
p.id === producto.id ? { ...p, stock: nuevoStock } : p
);
const nuevoMovimiento = {
id: uid(),
productoId: producto.id,
tipo: movForm.tipo,
cantidad,
fecha: movForm.fecha,
nota: movForm.nota,
};
let gastosActualizados = data.gastos || [];
if (movForm.tipo === "compra") {
const costoTotal = Number(movForm.costoTotal) || cantidad * Number(producto.costoUnitario || 0);
gastosActualizados = [
{
id: uid(),
fecha: movForm.fecha,
concepto: `Compra: ${producto.nombre} x${cantidad} ${producto.unidad}`,
categoria: "Insumos y jabones",
monto: costoTotal,
sucursalId,
},
...gastosActualizados,
];
}
save({
...data,
inventario: inventarioActualizado,
movimientosInventario: [nuevoMovimiento, ...(data.movimientosInventario || [])],
gastos: gastosActualizados,
});
setMovForm(null);
};
const nombreProducto = (id) => inventario.find((p) => p.id === id)?.nombre || "Producto eliminado";
const historial = [...movimientos].sort((a, b) => (a.fecha < b.fecha ? 1 : -1)).slice(0, 15);
return (
{!form ? (
Agregar producto
) : (
setForm({ ...form, nombre: v })} placeholder="Ej. Shampoo para autos" />
Unidad
setForm({ ...form, unidad: e.target.value })}
className="w-full rounded-lg px-3 py-2 text-sm outline-none mt-1"
>
{UNIDADES.map((u) => {u} )}
setForm({ ...form, stock: v })} />
setForm({ ...form, minimo: v })} />
setForm({ ...form, costoUnitario: v })} />
setForm(null)} onSave={guardarProducto} />
)}
{movForm && (
{movForm.tipo === "compra" ? : }
{movForm.tipo === "compra" ? "Registrar compra" : "Registrar uso"} · {nombreProducto(movForm.productoId)}
setMovForm({ ...movForm, cantidad: v })} />
{movForm.tipo === "compra" && (
setMovForm({ ...movForm, costoTotal: v })} placeholder="Se calcula con el costo unitario si lo dejas vacío" />
)}
setMovForm({ ...movForm, fecha: v })} />
setMovForm({ ...movForm, nota: v })} />
setMovForm(null)} onSave={guardarMovimiento} />
)}
{inventario.length === 0 && !form && (
Aún no tienes productos. Agrega jabón, cera, toallas o lo que uses a diario.
)}
{inventario.map((p) => {
const bajo = Number(p.stock) <= Number(p.minimo);
return (
{p.nombre}
{p.stock} {p.unidad}
{p.costoUnitario ? (
{money(p.costoUnitario)} por {p.unidad.replace(/s$/, "")}
) : null}
setForm(p)}>
eliminarProducto(p.id)} danger>
{bajo && (
Por debajo del mínimo ({p.minimo} {p.unidad})
)}
abrirMovimiento(p, "compra")}
className="flex-1 rounded-lg py-2 text-xs font-medium flex items-center justify-center gap-1"
style={{ background: SURFACE2, color: ACCENT }}
>
Entrada
abrirMovimiento(p, "uso")}
className="flex-1 rounded-lg py-2 text-xs font-medium flex items-center justify-center gap-1"
style={{ background: SURFACE2, color: GOLD }}
>
Salida
);
})}
{historial.length > 0 && (
Movimientos recientes
{historial.map((m) => (
{m.tipo === "compra" ? "+" : "−"}{m.cantidad} {nombreProducto(m.productoId)}
{m.nota ? ` · ${m.nota}` : ""}
{fecha(m.fecha)}
))}
)}
);
}
/* ---------------- utilidades UI ---------------- */
function Field({ label, value, onChange, type = "text", placeholder }) {
return (
{label}
onChange(e.target.value)}
className="w-full rounded-lg px-3 py-2 text-sm outline-none mt-1"
/>
);
}
function TextArea({ label, value, onChange, placeholder }) {
return (
{label}
);
}
function FormButtons({ onCancel, onSave }) {
return (
Cancelar
Guardar
);
}
function IconBtn({ children, onClick, danger }) {
return (
{children}
);
}
function LiveTimer({ startMs }) {
const [elapsed, setElapsed] = useState(Date.now() - startMs);
useEffect(() => {
const id = setInterval(() => setElapsed(Date.now() - startMs), 1000);
return () => clearInterval(id);
}, [startMs]);
const totalSec = Math.max(0, Math.floor(elapsed / 1000));
const mm = Math.floor(totalSec / 60);
const ss = totalSec % 60;
return (
{mm}:{ss.toString().padStart(2, "0")}
);
}
/* ------------------------------------------------------------------
Punto de montaje (agregado para ejecutar la app en el navegador).
El resto del archivo es el codigo original sin cambios.
------------------------------------------------------------------- */
import { createRoot } from "react-dom/client";
createRoot(document.getElementById("root")).render( );