/* ============================================
Admin Modules — Producción, Stock, Ventas, Terceros, Gastos, Rentabilidad, Maquinaria
============================================ */
/* ============================================
Producción
============================================ */
function TabBtn({ active, onClick, children }) {
return (
);
}
function ModalDetalleLote({ lote, onClose, initialTab }) {
const [tab, setTab] = useState(initialTab || "labores");
const [labores, setLabores] = useState([]);
const [cortes, setCortes] = useState([]);
const [fardos, setFardos] = useState([]);
const [showNew, setShowNew] = useState(false);
const [loading, setLoading] = useState(true);
// Rendimiento tab state
const [corteSelId, setCorteSelId] = useState("");
const [rndCants, setRndCants] = useState({});
const [rndKgTotal, setRndKgTotal] = useState("");
const [rndMsg, setRndMsg] = useState("");
const [rndSaving, setRndSaving] = useState(false);
const tipoLabel = { rastrillado:"Rastrillado", fumigacion:"Fumigación", fertilizacion:"Fertilización", herbicida:"Herbicida", riego:"Riego", siembra:"Siembra", otro:"Otro" };
const tipoIcon = { rastrillado:"Tractor", fumigacion:"Wind", fertilizacion:"Leaf", herbicida:"Sprout", riego:"Droplets", siembra:"Wheat", otro:"MoreHorizontal" };
const tipoCls = { rastrillado:"b-info", fumigacion:"b-gold", fertilizacion:"b-green", herbicida:"b-mute", riego:"b-info", siembra:"b-green", otro:"b-mute" };
const recargar = () => {
setLoading(true);
window.API.get(`/lotes/${lote.id}/detalle`).then(r => {
setLabores(r.labores || []);
setCortes(r.cortes || []);
if (r.cortes?.length && !corteSelId) setCorteSelId(String(r.cortes[0].id));
setLoading(false);
});
window.API.get("/fardos").then(r => {
if (r.data) {
const destino = lote.destino_produccion || "ambos";
setFardos(r.data.filter(f => {
if (destino === "rollos") return f.tipo === "redondo";
if (destino === "megas") return f.tipo === "mega";
return f.tipo === "redondo" || f.tipo === "mega"; // ambos
}));
}
});
};
useEffect(() => { recargar(); }, [lote.id]);
const guardarRendimiento = async () => {
const ultimoId = cortes[0]?.id;
const cant = Object.values(rndCants).reduce((s, v) => s + (parseInt(v) || 0), 0);
const kgTot = parseFloat(rndKgTotal) || null;
if (!cant) { setRndMsg("Ingresá al menos una cantidad."); return; }
const kgUnd = (cant && kgTot) ? parseFloat((kgTot / cant).toFixed(2)) : null;
const kgHa = (kgTot && lote.hectareas) ? parseFloat((kgTot / lote.hectareas).toFixed(2)) : null;
setRndSaving(true); setRndMsg("");
const cantidadesInt = {};
Object.entries(rndCants).forEach(([id, v]) => { const n = parseInt(v); if (n > 0) cantidadesInt[id] = n; });
const res = await window.API.post(`/cortes/${ultimoId}/rendimiento`, {
cantidad_total: cant,
kg_total: kgTot,
kg_por_unidad: kgUnd,
kg_por_ha: kgHa,
cantidades: cantidadesInt,
});
setRndSaving(false);
if (res.mensaje) {
setRndMsg("✓ " + res.mensaje);
setRndCants({}); setRndKgTotal("");
recargar();
} else { setRndMsg(res.message || "Error al guardar."); }
};
return (
e.stopPropagation()}
style={{ maxWidth:760, width:"92vw", maxHeight:"88vh", display:"flex", flexDirection:"column" }}>
{lote.hectareas} ha · {lote.tenencia} · {lote.destino_produccion === "rollos" ? "Solo rollos" : lote.destino_produccion === "megas" ? "Solo megas" : "Rollos y megas"}
{lote.nombre}
{tab === "labores" && (
)}
setTab("labores")}>Labores
setTab("cortes")}>Cortes
setTab("rendimiento")}>Rendimiento
{loading ? (
Cargando...
) : tab === "labores" ? (
labores.length === 0
?
Sin labores. Usá "Registrar labor".
:
{labores.map(lb => (
{tipoLabel[lb.tipo] || lb.tipo}
{lb.fecha}
{lb.producto &&
{lb.producto}
}
{lb.observaciones &&
{lb.observaciones}
}
))}
) : tab === "cortes" ? (
cortes.length === 0
?
Sin cortes registrados.
: (() => {
const cortesConRend = cortes.filter(c => c.cantidad_total > 0);
const totalFardos = cortesConRend.reduce((s, c) => s + (c.cantidad_total || 0), 0);
const totalKg = cortesConRend.reduce((s, c) => s + (c.kg_total || 0), 0);
return (
| Corte | Fecha | Producto | Fardos | Kg total | Kg/unidad | Kg/ha | Observaciones |
{cortes.map(c => (
| {c.numero_corte}° |
{c.fecha} |
{c.producto ? {c.producto} : —} |
{c.cantidad_total ?? —} |
{c.kg_total ? c.kg_total.toLocaleString("es-AR") + " kg" : —} |
{c.kg_por_unidad ? c.kg_por_unidad + " kg" : —} |
{c.kg_por_ha ? c.kg_por_ha + " kg/ha" : —} |
{c.observaciones || "—"} |
))}
{cortesConRend.length > 1 && (
| Total campaña |
{totalFardos} |
{totalKg ? totalKg.toLocaleString("es-AR") + " kg" : "—"} |
— |
— |
|
)}
);
})()
) : (
/* ── TAB RENDIMIENTO ── */
cortes.length === 0
?
Registrá un corte primero.
: (() => {
const ultimoCorte = cortes[0];
const sinRendimiento = !ultimoCorte?.cantidad_total;
const rndCantN = Object.values(rndCants).reduce((s, v) => s + (parseInt(v) || 0), 0);
const rndKgN = parseFloat(rndKgTotal) || null;
const kgUnd = (rndCantN && rndKgN) ? parseFloat((rndKgN / rndCantN).toFixed(2)) : null;
const kgHa = (rndKgN && lote.hectareas) ? parseFloat((rndKgN / lote.hectareas).toFixed(2)) : null;
return (
{/* ── Cargar rendimiento del último corte ── */}
{sinRendimiento && (
Cargar rendimiento — {ultimoCorte.numero_corte}° corte · {ultimoCorte.fecha}
{fardos.length === 0 ? (
Sin productos en Stock para este lote.
) : (
<>
¿Qué se produjo en este corte?
{fardos.map(f => (
{f.tipo === "redondo" ? "Rollo" : "Mega"}
{f.nombre}
stock actual: {f.stock} unidades
setRndCants(prev => ({ ...prev, [f.id]: e.target.value }))}
/>
))}
{rndCantN > 0 && (
setRndKgTotal(e.target.value)}
placeholder="Ej: 60000" />
)}
{(kgUnd || kgHa) && (
{kgUnd && Promedio por unidad: {kgUnd} kg}
{kgHa && Kg por hectárea: {kgHa} kg/ha}
)}
{rndMsg && {rndMsg}}
>
)}
)}
{!sinRendimiento && rndMsg && (
{rndMsg}
)}
);
})()
)}
{showNew && (
setShowNew(false)}
onSuccess={() => { setShowNew(false); recargar(); }}
/>
)}
);
}
function ModalNuevaLabor({ loteId, loteName, onClose, onSuccess }) {
const tiposLabor = [
{ value:"rastrillado", label:"Rastrillado" },
{ value:"fumigacion", label:"Fumigación" },
{ value:"fertilizacion", label:"Fertilización" },
{ value:"herbicida", label:"Herbicida" },
{ value:"riego", label:"Riego" },
{ value:"siembra", label:"Siembra" },
{ value:"otro", label:"Otro" },
];
const [tipo, setTipo] = useState("rastrillado");
const [fecha, setFecha] = useState(new Date().toISOString().slice(0,10));
const [producto, setProducto]= useState("");
const [obs, setObs] = useState("");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const submit = async (e) => {
e.preventDefault();
setErr(""); setLoading(true);
const res = await window.API.post("/labores", {
lote_id: loteId, tipo, fecha,
producto: producto || null,
observaciones: obs || null,
});
setLoading(false);
if (res.data?.id || res.id) { onSuccess(); }
else { setErr(res.message || "Error al guardar."); }
};
return (
e.stopPropagation()} style={{ maxWidth:420 }}>
Registrar labor · {loteName}
);
}
function ModalNuevaCampana({ onClose, onSuccess }) {
const hoy = new Date();
const [nombre, setNombre] = useState("");
const [inicio, setInicio] = useState("");
const [fin, setFin] = useState("");
const [activa, setActiva] = useState(true);
const [obs, setObs] = useState("");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const autoCompletar = (nombreVal) => {
setNombre(nombreVal);
const match = nombreVal.match(/^(\d{4})\/(\d{2,4})$/);
if (match) {
const anio = parseInt(match[1]);
setInicio(`${anio}-07-01`);
setFin(`${anio + 1}-06-30`);
}
};
const submit = async (e) => {
e.preventDefault();
setErr(""); setLoading(true);
const res = await window.API.post("/campanas", {
nombre, fecha_inicio: inicio, fecha_fin: fin, activa, observaciones: obs || null,
});
setLoading(false);
if (res.data?.id || res.id) {
window.toast(`Campaña ${nombre} creada`);
onSuccess();
onClose();
} else {
setErr(res.message || "Error al guardar.");
}
};
return (
e.stopPropagation()} style={{ maxWidth: 440 }}>
);
}
function Produccion() {
const D = window.DATA;
const [tab, setTab] = useState("todos");
const [filter, setFilter] = useState("todos");
const [showModal, setShowModal] = useState(false);
const [showMapa, setShowMapa] = useState(false);
const [showNuevoLote, setShowNuevoLote] = useState(false);
const [showNuevaCampana, setShowNuevaCampana] = useState(false);
const [loteSel, setLoteSel] = useState(null);
const [loteSelTab, setLoteSelTab] = useState("labores");
const [loteEditar, setLoteEditar] = useState(null);
const [LOTES, setLOTES] = useState([]);
const [campanas, setCampanas] = useState([]);
const [campanaSelId, setCampanaSelId] = useState(null);
const campanaActiva = campanas.find(c => c.activa) || campanas[0] || null;
const campanaSel = campanas.find(c => c.id === campanaSelId) || campanaActiva;
const recargarCampanas = () => {
window.API.get("/campanas").then(r => {
if (r.data) {
setCampanas(r.data);
if (!campanaSelId) {
const activa = r.data.find(c => c.activa);
if (activa) setCampanaSelId(activa.id);
}
}
});
};
const recargarLotes = () => {
window.API.get("/lotes").then(r => {
if (r.data) setLOTES(r.data.map(window.API_MAP.lote));
});
};
useEffect(() => { recargarLotes(); recargarCampanas(); }, []);
const lotesPorTenencia = (t) => LOTES.filter(l => l.tenencia === t);
const totalHa = (t) => lotesPorTenencia(t).reduce((s, l) => s + l.hectareas, 0);
const tabsData = [
{ id: "todos", label: "Todos los campos", count: LOTES.length, has: LOTES.reduce((s,l) => s + l.hectareas, 0), color: "var(--green-700)", icon: "LayoutGrid" },
{ id: "propio", label: "Propios", count: lotesPorTenencia("propio").length, has: totalHa("propio"), color: "var(--green-800)", icon: "Home" },
{ id: "alquilado", label: "Alquilados", count: lotesPorTenencia("alquilado").length, has: totalHa("alquilado"), color: "var(--gold-700)", icon: "KeyRound" },
{ id: "sociedad", label: "En sociedad", count: lotesPorTenencia("sociedad").length, has: totalHa("sociedad"), color: "var(--earth-500)", icon: "Handshake" },
];
const baseLotes = tab === "todos" ? LOTES : LOTES.filter(l => l.tenencia === tab);
const filtered = baseLotes.filter(l => filter === "todos" ? true : l.estado === filter);
const tenenciaLabel = { propio: "Propio", alquilado: "Alquilado", sociedad: "Sociedad" };
const tenenciaCls = { propio: "b-green", alquilado: "b-gold", sociedad: "b-info" };
return (
Producción de lotes
{LOTES.length} lotes · {LOTES.reduce((s,l) => s + l.hectareas, 0)} ha totales · entre propios, alquilados y en sociedad
{campanaSel ? (
Campaña
) : (
)}
{tabsData.map(t => (
setTab(t.id)}
style={{ cursor: "pointer", borderColor: tab === t.id ? t.color : undefined, boxShadow: tab === t.id ? `0 0 0 2px ${t.color}30` : undefined, transition: "all 140ms" }}>
{t.label}
{t.has} ha
{t.id === "propio" && "campos de la empresa"}
{t.id === "alquilado" && "contratos vigentes"}
{t.id === "sociedad" && "con 2 empresas socias"}
{t.id === "todos" && "superficie en producción"}
))}
{tab === "todos" ? "Todos los lotes" : `Campos ${tab === "propio" ? "propios" : tab === "alquilado" ? "alquilados" : "en sociedad"}`}
{filtered.length} lotes · {filtered.reduce((s,l) => s + l.hectareas, 0)} ha
{[["todos","Todos"],["activo","Activos"],["descanso","En descanso"]].map(([k,l]) => (
))}
| Lote | Uso | Tenencia | Has | Último corte | Producción total | Estado | |
{filtered.map(l => (
Lote {l.nombre}
{l.tenencia === "alquilado" && de {l.dueño} }
{l.tenencia === "sociedad" && con {l.socio} }
|
{(() => {
const d = l.destino_produccion || "ambos";
const label = d === "rollos" ? "Rollos" : d === "megas" ? "Megas" : "Rollos y megas";
const cls = d === "rollos" ? "b-info" : d === "megas" ? "b-gold" : "b-green";
return {label};
})()}
|
{tenenciaLabel[l.tenencia]}
{l.participacion && {l.participacion} }
{l.contrato && {l.contrato} }
|
{l.hectareas} |
{l.ultCorte} |
{l.ultCorteFardos > 0
?
: —}
|
|
|
))}
{filtered.length === 0 && (
| No hay lotes con esos filtros. |
)}
{tab === "sociedad" && (
{lotesPorTenencia("sociedad").map(l => (
Lote {l.nombre}
Sociedad con {l.socio}
{l.hectareas} ha
Participación {l.participacion}
))}
)}
{showModal &&
setShowModal(false)} onSuccess={recargarLotes} />}
{showMapa && setShowMapa(false)} onSuccess={recargarLotes} />}
{showNuevoLote && setShowNuevoLote(false)} onSuccess={recargarLotes} />}
{showNuevaCampana && setShowNuevaCampana(false)} onSuccess={recargarCampanas} />}
{loteSel && setLoteSel(null)} />}
{loteEditar && setLoteEditar(null)} onSuccess={recargarLotes} />}
);
}
function ModalMapaLotes({ lotes, onClose, onSuccess }) {
const mapRef = React.useRef(null);
const mapInst = React.useRef(null);
const lyrs = React.useRef({});
const drawLyr = React.useRef(null);
const pts = React.useRef([]);
const [modo, setModo] = useState(null); // lote en edición
const [nPts, setNPts] = useState(0);
const [msg, setMsg] = useState("");
const [guardando, setGuardando] = useState(false);
const [lotesLocal,setLotesLocal]= useState(lotes);
const renderLotes = (map, ls) => {
Object.values(lyrs.current).forEach(l => { try { map.removeLayer(l); } catch {} });
lyrs.current = {};
const bounds = [];
ls.forEach(lote => {
if (!lote.poligono?.length) return;
const colores = { propio:"#2d5016", alquilado:"#d97706", sociedad:"#7c5b3e" };
const col = colores[lote.tenencia] || "#2d5016";
const poly = window.Leaflet.polygon(lote.poligono, { color: col, fillColor: col, fillOpacity: 0.28, weight: 2.5 });
poly.bindTooltip(
`${lote.nombre}
${lote.hectareas} ha · ${lote.estado}`,
{ sticky: true, direction:"top" }
);
poly.addTo(map);
lyrs.current[lote.id] = poly;
bounds.push(...lote.poligono);
});
if (bounds.length) { try { map.fitBounds(bounds, { padding:[30,30] }); } catch {} }
};
useEffect(() => {
if (!mapRef.current || mapInst.current || !window.L) return;
const map = window.Leaflet.map(mapRef.current, { center: [-31.6656, -63.2279], zoom: 14 });
window.Leaflet.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{ maxZoom: 19, attribution: '© Esri World Imagery' }
).addTo(map);
window.Leaflet.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
{ maxZoom: 19, opacity: 0.6 }
).addTo(map);
mapInst.current = map;
renderLotes(map, lotes);
return () => { map.remove(); mapInst.current = null; lyrs.current = {}; };
}, []);
const iniciarDibujo = (lote) => {
const map = mapInst.current;
if (!map) return;
cancelarDibujo();
setModo(lote);
pts.current = [];
setNPts(0);
setMsg(`Hacé click en el campo para trazar los vértices de "${lote.nombre}"`);
map.getContainer().style.cursor = "crosshair";
const onClick = (e) => {
pts.current.push([e.latlng.lat, e.latlng.lng]);
setNPts(pts.current.length);
if (drawLyr.current) map.removeLayer(drawLyr.current);
drawLyr.current = pts.current.length >= 2
? window.Leaflet.polygon(pts.current, { color:"#22c55e", fillOpacity:0.15, dashArray:"6,4", weight:2 }).addTo(map)
: window.Leaflet.circleMarker(e.latlng, { radius:5, color:"#22c55e", fillOpacity:1 }).addTo(map);
};
map._drawHandler = onClick;
map.on("click", onClick);
};
const cancelarDibujo = () => {
const map = mapInst.current;
if (!map) return;
if (map._drawHandler) { map.off("click", map._drawHandler); map._drawHandler = null; }
if (drawLyr.current) { map.removeLayer(drawLyr.current); drawLyr.current = null; }
if (map.getContainer()) map.getContainer().style.cursor = "";
setModo(null); setNPts(0); pts.current = [];
};
const guardar = async () => {
if (pts.current.length < 3) { setMsg("Mínimo 3 vértices."); return; }
setGuardando(true);
const lat = pts.current.reduce((s,p) => s+p[0], 0) / pts.current.length;
const lng = pts.current.reduce((s,p) => s+p[1], 0) / pts.current.length;
await window.API.post(`/lotes/${modo.id}/poligono`, { poligono: pts.current, lat_centro: lat, lng_centro: lng });
const updated = lotesLocal.map(l => l.id === modo.id ? {...l, poligono: pts.current} : l);
setLotesLocal(updated);
renderLotes(mapInst.current, updated);
cancelarDibujo();
setMsg("✓ Polígono guardado");
setGuardando(false);
if (onSuccess) onSuccess();
};
const borrar = async (lote) => {
await window.API.post(`/lotes/${lote.id}/poligono`, { poligono: null });
const updated = lotesLocal.map(l => l.id === lote.id ? {...l, poligono: null} : l);
setLotesLocal(updated);
if (lyrs.current[lote.id]) { mapInst.current.removeLayer(lyrs.current[lote.id]); delete lyrs.current[lote.id]; }
};
return (
e.stopPropagation()} style={{ maxWidth: 860, width:"92vw" }}>
Mapa de lotes
{msg &&
{msg}}
{modo && <>
{nPts} vértices
{nPts >= 3 && }
>}
{!modo && }
{!modo &&
DIBUJAR:}
{!modo && lotesLocal.map(l => (
{l.poligono?.length > 0 && (
)}
))}
Imagen satelital ESRI · sin costo · sin API key
Propio
Alquilado
Sociedad
);
}
function ModalRegistrarCorte({ lotes, onClose, onSuccess }) {
const lotesActivos = lotes.filter(l => l.estado !== "descanso" && l.estado !== "implantacion");
const [loteId, setLoteId] = useState(String(lotesActivos[0]?.id || ""));
const [fecha, setFecha] = useState(lotesActivos[0]?.proxCorteISO || new Date().toISOString().slice(0,10));
// Cuando cambia el lote, autocompleta fecha con su próximo corte
useEffect(() => {
const lote = lotesActivos.find(l => String(l.id) === loteId);
if (lote?.proxCorteISO) setFecha(lote.proxCorteISO);
}, [loteId]);
const [tipo, setTipo] = useState("");
const [obs, setObs] = useState("");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [err, setErr] = useState("");
const loteSeleccionado = lotesActivos.find(l => String(l.id) === loteId);
const numeroCorte = (loteSeleccionado?.cortesCount || 0) + 1;
const submit = async (e) => {
e.preventDefault();
setErr("");
if (!loteId) { setErr("Seleccioná un lote."); return; }
if (!fecha) { setErr("Ingresá la fecha del corte."); return; }
if (!tipo) { setErr("Seleccioná el tipo de corte (picado o entero)."); return; }
if (loteSeleccionado?.ultCorteISO && fecha <= loteSeleccionado.ultCorteISO) {
setErr(`La fecha debe ser posterior al último corte (${loteSeleccionado.ultCorte}).`); return;
}
setLoading(true);
const res = await window.API.post("/cortes", {
lote_id: parseInt(loteId),
numero_corte: numeroCorte,
fecha_corte: fecha,
tipo: tipo || null,
observaciones: obs || null,
});
setLoading(false);
if (res.data?.id || res.id) {
setDone(true);
onSuccess && onSuccess();
setTimeout(onClose, 1400);
} else {
const msgs = res.errors ? Object.values(res.errors).flat().join(" ") : res.message;
setErr(msgs || "Error al guardar.");
}
};
return (
e.stopPropagation()}>
{done ? (
Corte registrado. Lote {loteSeleccionado?.nombre} actualizado.
) : (
)}
);
}
function ModalEditarLote({ lote, onClose, onSuccess }) {
const toDate = (str) => str ? str.split("/").reverse().join("-") : "";
const [nombre, setNombre] = useState(lote.nombre || "");
const [hectareas, setHectareas] = useState(String(lote.hectareas || ""));
const [tenencia, setTenencia] = useState(lote.tenencia || "propio");
const [estado, setEstado] = useState(lote.estado || "activo");
const [destino, setDestino] = useState(lote.destino_produccion || "ambos");
const [dueno, setDueno] = useState(lote.dueño || "");
const [contratoHasta, setContratoHasta] = useState(toDate(lote.contrato?.replace("Hasta ", "")));
const [socio, setSocio] = useState(lote.socio || "");
const [participacion, setParticipacion] = useState(lote.participacion || "");
const [obs, setObs] = useState(lote.observaciones || "");
const [loading, setLoading] = useState(false);
const [err, setErr] = useState("");
const submit = async (e) => {
e.preventDefault();
setLoading(true); setErr("");
const body = {
nombre, hectareas: parseFloat(hectareas), tenencia, estado,
destino_produccion: destino,
observaciones: obs || null,
};
if (tenencia === "alquilado") { body.dueno = dueno; body.contrato_hasta = contratoHasta || null; }
if (tenencia === "sociedad") { body.socio = socio; body.participacion = participacion; }
const res = await window.API.put(`/lotes/${lote.id}`, body);
setLoading(false);
if (res.data?.id || res.id) {
window.toast(`Lote "${nombre}" actualizado`);
onSuccess && onSuccess();
onClose();
} else {
const msgs = res.errors ? Object.values(res.errors).flat().join(" ") : res.message;
setErr(msgs || "Error al guardar.");
}
};
return (
e.stopPropagation()}>
Editar lote · {lote.nombre}
);
}
function ModalNuevoLote({ onClose, onSuccess }) {
const [nombre, setNombre] = useState("");
const [hectareas, setHectareas] = useState("");
const [tenencia, setTenencia] = useState("propio");
const [estado, setEstado] = useState("activo");
const [destino, setDestino] = useState("ambos");
const [dueno, setDueno] = useState("");
const [contratoHasta,setContratoHasta]= useState("");
const [socio, setSocio] = useState("");
const [participacion,setParticipacion]= useState("");
const [obs, setObs] = useState("");
const [sociosDB, setSociosDB] = useState([]);
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [err, setErr] = useState("");
useEffect(() => {
window.API.get("/proveedores").then(r => {
if (r.data) setSociosDB(r.data.map(s => s.nombre));
});
}, []);
const toISO = (str) => {
if (!str) return null;
const [d, m, y] = str.split("/");
return y ? `${y}-${m}-${d}` : null;
};
const submit = async (e) => {
e.preventDefault();
setLoading(true); setErr("");
const body = {
nombre,
hectareas: parseFloat(hectareas),
tenencia,
estado,
destino_produccion: destino,
observaciones: obs || null,
};
if (tenencia === "alquilado") { body.dueno = dueno; body.contrato_hasta = toISO(contratoHasta); }
if (tenencia === "sociedad") { body.socio = socio; body.participacion = participacion; }
const res = await window.API.post("/lotes", body);
setLoading(false);
if (res.data?.id || res.id) {
setDone(true);
onSuccess && onSuccess();
setTimeout(onClose, 1400);
} else {
const msgs = res.errors ? Object.values(res.errors).flat().join(" ") : res.message;
setErr(msgs || "Error al guardar.");
}
};
return (
e.stopPropagation()}>
{done ? (
Lote "{nombre}" agregado correctamente.
) : (
)}
);
}
/* ============================================
Stock
============================================ */
function Stock() {
const D = window.DATA;
const [showMov, setShowMov] = useState(false);
const [showNuevo, setShowNuevo] = useState(false);
const [PRODUCTS, setPRODUCTS] = useState([]);
const [movTipo, setMovTipo] = useState("ingreso");
const [movProdId, setMovProdId] = useState("");
const [movCant, setMovCant] = useState("");
const [asignarLote,setAsignarLote]= useState(null);
const [LOTES_STOCK,setLOTES_STOCK]= useState([]);
const [prodSel, setProdSel] = useState(null);
const recargarFardos = () => {
window.API.get("/fardos").then(r => {
if (r.data) setPRODUCTS(r.data.map(window.API_MAP.fardo));
});
window.API.get("/lotes").then(r => {
if (r.data) setLOTES_STOCK(r.data.map(l => ({ id: l.id, nombre: l.nombre })));
});
};
useEffect(() => { recargarFardos(); }, []);
const totalChico = PRODUCTS.filter(p => p.tipo === "chico").reduce((s,p) => s + p.stock, 0);
const totalRedondo = PRODUCTS.filter(p => p.tipo === "redondo").reduce((s,p) => s + p.stock, 0);
const totalMega = PRODUCTS.filter(p => p.tipo === "mega").reduce((s,p) => s + p.stock, 0);
const valorTotal = PRODUCTS.reduce((s,p) => s + p.stock * p.precio, 0);
return (
Stock de fardos
Stock de fardos activo
p.tipo==='redondo').length} productos`} icon="Circle" />
p.tipo==='mega').length} productos`} icon="Package" />
Detalle por tipo y corte
{PRODUCTS.length} SKUs
| Producto | Tipo | Peso | Proteína | Galpón | Stock | Precio | Estado |
{PRODUCTS.map(p => {
const niv = p.stock <= 15 ? "bad" : p.stock <= 50 ? "warn2" : p.stock <= 120 ? "warn" : "ok";
return (
setProdSel(p)}
onMouseEnter={e => e.currentTarget.style.background="var(--admin-bg)"}
onMouseLeave={e => e.currentTarget.style.background=""}>
| {p.nombre} |
{p.tipo==="redondo"?"Rollo":"Mega"} |
{p.peso} |
{p.proteina} |
{p.galpon} |
{D.fmtNum(p.stock)} |
{p.precio > 0 ? D.fmtARS(p.precio) : "—"} |
{niv === "ok" ? "Stock alto" : niv === "warn" ? "Stock medio" : niv === "warn2" ? "Stock casi bajo" : "Stock bajo"}
|
);
})}
{showMov && (
setShowMov(false)}>
e.stopPropagation()}>
Movimiento de stock
)}
{showNuevo &&
setShowNuevo(false)} onSuccess={recargarFardos} />}
{prodSel && setProdSel(null)} onSuccess={() => { setProdSel(null); recargarFardos(); }} />}
{asignarLote && (
setAsignarLote(null)}>
e.stopPropagation()} style={{ maxWidth:380 }}>
Lote de origen · {asignarLote.nombre}
)}
);
}
function ModalEditarFardo({ fardo, onClose, onSuccess }) {
const D = window.DATA;
const realId = fardo.id.replace("p", "");
const [nombre, setNombre] = useState(fardo.nombre || "");
const [tipo, setTipo] = useState(fardo.tipo || "redondo");
const [peso, setPeso] = useState(String(parseFloat(fardo.peso) || ""));
const [proteina, setProteina] = useState(parseFloat(fardo.proteina) || "");
const [calidad, setCalidad] = useState(fardo.calidad || "Estándar");
const [galpon, setGalpon] = useState(fardo.galpon !== "—" ? fardo.galpon : "");
const [stock, setStock] = useState(String(fardo.stock || 0));
const [precio, setPrecio] = useState(String(fardo.precio || 0));
const [corteId, setCorteId] = useState(fardo.corte_id || "");
const [cortes, setCortes] = useState([]);
const [tab, setTab] = useState("info");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const [confirmar, setConfirmar] = useState(false);
useEffect(() => {
// Cargar fardo fresco para tener corte_id actualizado
window.API.get(`/fardos/${realId}`).then(r => {
if (r.data) setCorteId(r.data.corte_id || "");
});
window.API.get("/cortes").then(r => {
if (r.data) setCortes(r.data);
});
}, [realId]);
const corteSeleccionado = cortes.find(c => String(c.id) === String(corteId));
const guardar = async (e) => {
e.preventDefault();
setErr(""); setLoading(true);
const body = {
nombre, tipo,
peso_kg: parseFloat(peso),
proteina_pct: proteina ? parseFloat(proteina) : null,
calidad,
galpon: galpon || null,
stock: parseInt(stock) || 0,
precio: parseFloat(precio) || 0,
corte_id: corteId ? parseInt(corteId) : null,
};
const res = await window.API.patch(`/fardos/${realId}`, body);
setLoading(false);
if (res.data?.id || res.id) { window.toast("Fardo actualizado"); onSuccess(); }
else { setErr(res.message || "Error al guardar."); }
};
const eliminar = async () => {
await window.API.del(`/fardos/${realId}`);
window.toast(`"${fardo.nombre}" eliminado`);
onSuccess();
};
const TabBtn2 = ({ id, label }) => (
);
return (
e.stopPropagation()} style={{ maxWidth:500 }}>
{tipo === "redondo" ? "Rollo" : "Mega"} · stock: {fardo.stock}
{fardo.nombre}
{tab === "info" ? (
) : (
Hacé click en el corte al que pertenece este fardo
{(() => {
const cortesFiltrados = cortes.filter(c => {
if (tipo === "redondo") return c.cantidad_rollos > 0;
if (tipo === "mega") return c.cantidad_megas > 0;
return true;
});
if (cortesFiltrados.length === 0) {
return
Sin cortes con producción de {tipo === "redondo" ? "rollos" : "megas"}.
;
}
return cortesFiltrados.map(c => {
const sel = String(c.id) === String(corteId);
const rend = [];
if (c.cantidad_rollos > 0) rend.push(c.cantidad_rollos + " rollos");
if (c.cantidad_megas > 0) rend.push(c.cantidad_megas + " megas");
return (
{
const newId = sel ? "" : c.id;
setCorteId(newId);
await window.API.patch(`/fardos/${realId}`, { corte_id: newId ? parseInt(newId) : null });
window.toast(newId ? "Corte asignado" : "Corte desvinculado");
}}
style={{
display:"flex", alignItems:"center", gap:12, padding:"10px 14px",
borderRadius:8, cursor:"pointer", border:"2px solid",
borderColor: sel ? "var(--green-800)" : "var(--admin-line)",
background: sel ? "#f0f7eb" : "#fff",
transition:"all 120ms",
}}>
{c.numero_corte}°
{c.lote_nombre || "—"}
{c.fecha_corte}
{rend.length > 0 && {rend.join(" / ")}}
{rend.length === 0 && sin rendimiento registrado}
{sel &&
}
);
});
})()}
{corteId && (
)}
)}
);
}
function ModalNuevoFardo({ onClose, onSuccess }) {
const [nombre, setNombre] = useState("");
const [tipo, setTipo] = useState("redondo");
const [peso, setPeso] = useState("");
const [proteina, setProteina] = useState("");
const [calidad, setCalidad] = useState("Estándar");
const [galpon, setGalpon] = useState("");
const [stock, setStock] = useState("0");
const [precio, setPrecio] = useState("");
const [destacado, setDestacado] = useState(false);
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [err, setErr] = useState("");
const pesoDefault = { redondo: "350", mega: "500" };
const submit = async (e) => {
e.preventDefault();
setLoading(true); setErr("");
const res = await window.API.post("/fardos", {
nombre,
tipo,
peso_kg: parseFloat(peso || pesoDefault[tipo]),
proteina_pct: proteina ? parseFloat(proteina) : null,
calidad,
galpon: galpon || null,
stock: parseInt(stock) || 0,
precio: parseFloat(precio) || 0,
destacado,
});
setLoading(false);
if (res.data?.id || res.id) {
setDone(true);
onSuccess && onSuccess();
setTimeout(onClose, 1400);
} else {
const msgs = res.errors ? Object.values(res.errors).flat().join(" ") : res.message;
setErr(msgs || "Error al guardar.");
}
};
return (
e.stopPropagation()}>
{done ? (
Fardo "{nombre}" agregado al stock.
) : (
)}
);
}
function StockTotal({ title, value, hint, icon, gold }) {
return (
);
}
/* ============================================
Ventas y clientes
============================================ */
function ModalNuevaVenta({ clientes, fardos, onClose, onSuccess }) {
const D = window.DATA;
const [clienteId, setClienteId] = useState("");
const [clienteNom, setClienteNom] = useState("");
const [fardoId, setFardoId] = useState("");
const [producto, setProducto] = useState("");
const [cantidad, setCantidad] = useState("");
const [precio, setPrecio] = useState("");
const [fecha, setFecha] = useState(new Date().toISOString().slice(0,10));
const [formaPago, setFormaPago] = useState("transferencia");
const [estado, setEstado] = useState("pagado");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const onClienteChange = (e) => {
const id = e.target.value;
const cli = clientes.find(c => String(c.id) === id);
setClienteId(id);
setClienteNom(cli ? cli.nombre : "");
};
const onFardoChange = (e) => {
const id = e.target.value;
const f = fardos.find(x => String(x.id) === id);
setFardoId(id);
setProducto(f ? f.nombre : "");
if (f && f.precio > 0) setPrecio(String(f.precio));
};
const submit = async (e) => {
e.preventDefault();
setErr(""); setLoading(true);
const body = {
cliente_id: clienteId || null,
cliente_nombre: clienteNom || "Sin cliente",
fardo_id: fardoId || null,
producto: producto || "Producto sin especificar",
cantidad: parseInt(cantidad),
precio_unitario: parseFloat(precio),
fecha,
forma_pago: formaPago,
estado,
};
const res = await window.API.post("/ventas", body);
setLoading(false);
if (res.data?.id || res.id) {
window.toast("Venta registrada correctamente");
onSuccess();
onClose();
} else {
const msgs = res.errors ? Object.values(res.errors).flat().join(" · ") : (res.message || "Error al guardar.");
setErr(msgs);
}
};
const monto = (parseFloat(cantidad) || 0) * (parseFloat(precio) || 0);
return (
e.stopPropagation()} style={{ maxWidth: 520 }}>
);
}
function VentasModulo() {
const D = window.DATA;
const [showVenta, setShowVenta] = useState(false);
const [showCliente, setShowCliente] = useState(false);
const [CLIENTES, setCLIENTES] = useState([]);
const [clienteSel, setClienteSel] = useState(null);
const [VENTAS, setVENTAS] = useState([]);
const [FARDOS, setFARDOS] = useState([]);
const [resumen, setResumen] = useState({ total_mes: 0, total_mes_ant: 0, pendiente: 0, ticket_promedio: 0, count_mes: 0 });
const recargar = () => {
window.API.get("/clientes").then(r => {
if (r.data) setCLIENTES(r.data.map(c => ({
id: String(c.id), nombre: c.nombre, tipo: c.tipo || "—", ciudad: c.ciudad || "—",
email: c.email || "", telefono: c.telefono || "", cuit: c.cuit || "",
saldo: c.saldo_pendiente || 0, total: c.total_facturado || 0,
ventasCount: c.ventas_count || 0, observaciones: c.observaciones || "",
})));
});
window.API.get("/ventas").then(r => {
if (r.data) setVENTAS(r.data);
});
window.API.get("/ventas-resumen").then(r => {
if (r.total_mes !== undefined) setResumen(r);
});
window.API.get("/fardos").then(r => {
if (r.data) setFARDOS(r.data);
});
};
useEffect(() => { recargar(); }, []);
const topData = VENTAS.reduce((acc, v) => {
const key = v.cliente;
acc[key] = (acc[key] || 0) + v.monto;
return acc;
}, {});
const topClientes = Object.entries(topData).sort((a,b) => b[1]-a[1]).slice(0,6)
.map(([name, total]) => ({ name: name.length > 18 ? name.slice(0,17)+"…" : name, total }));
const deltasMes = resumen.total_mes_ant > 0
? Math.round(((resumen.total_mes - resumen.total_mes_ant) / resumen.total_mes_ant) * 100)
: null;
return (
Ventas y clientes
{CLIENTES.length} clientes · {VENTAS.length} ventas registradas
0 ? "+" : ""}${deltasMes}%` : ""} deltaDir={deltasMes > 0 ? "up" : "down"} hint="vs. mes anterior" />
Ventas recientes
{VENTAS.length} ventas
| Fecha | Cliente | Producto | Cant. | Monto | Estado |
{VENTAS.length === 0 && (
| Sin ventas cargadas. Usá "Nueva venta" para registrar. |
)}
{VENTAS.map(v => (
| {v.fecha} |
{v.cliente} |
{v.producto} |
{v.cant} |
{D.fmtARS(v.monto)} |
|
))}
Top clientes
Por monto facturado
{topClientes.length}
{topClientes.length === 0
?
Sin ventas aún
: (() => {
const max = Math.max(...topClientes.map(c => c.total));
const medals = ["#facc15", "#94a3b8", "#b45309"];
return topClientes.map((c, i) => {
const pct = max > 0 ? (c.total / max) * 100 : 0;
const medal = medals[i];
return (
{i+1}
{c.name}
{D.fmtARS(c.total)}
);
});
})()
}
Listado de clientes
Registrados en el sistema
{resumen.pendiente > 0 && (
Total a cobrar: {D.fmtARS(resumen.pendiente)}
)}
| Cliente | Tipo | Ciudad | Teléfono | Email | Cuenta corriente |
{CLIENTES.length === 0 && (
| Sin clientes cargados. Usá "Nuevo cliente" para agregar. |
)}
{CLIENTES.map(c => (
setClienteSel(c)}
onMouseEnter={e => e.currentTarget.style.background="var(--admin-bg)"}
onMouseLeave={e => e.currentTarget.style.background=""}>
{c.nombre.split(" ").map(s=>s[0]).slice(0,2).join("")}
{c.nombre}
|
{c.tipo} |
{c.ciudad} |
{c.telefono || "—"} |
{c.email || "—"} |
{c.saldo > 0
? {D.fmtARS(c.saldo)}
: Al día}
|
))}
{showCliente &&
setShowCliente(false)} onSuccess={recargar} />}
{showVenta && setShowVenta(false)} onSuccess={recargar} />}
{clienteSel && setClienteSel(null)} onSuccess={() => { setClienteSel(null); recargar(); }} onRefresh={recargar} />}
);
}
function ModalDetalleCliente({ cliente, onClose, onSuccess, onRefresh }) {
const D = window.DATA;
const [tab, setTab] = useState("datos");
const [nombre, setNombre] = useState(cliente.nombre || "");
const [tipo, setTipo] = useState(cliente.tipo || "tambo");
const [ciudad, setCiudad] = useState(cliente.ciudad !== "—" ? cliente.ciudad : "");
const [telefono, setTelefono] = useState(cliente.telefono || "");
const [email, setEmail] = useState(cliente.email || "");
const [cuit, setCuit] = useState(cliente.cuit || "");
const [obs, setObs] = useState(cliente.observaciones || "");
const [historial, setHistorial] = useState({ ventas: [], cliente: null });
const [loading, setLoading] = useState(false);
const [err, setErr] = useState("");
const [confirmar, setConfirmar] = useState(false);
const [pagoSel, setPagoSel] = useState(null); // venta a marcar pagada
useEffect(() => {
window.API.get(`/clientes/${cliente.id}/historial`).then(r => {
if (r.ventas) setHistorial(r);
});
}, [cliente.id]);
const guardar = async (e) => {
e.preventDefault();
setErr(""); setLoading(true);
const body = {
nombre, tipo,
ciudad: ciudad || null,
telefono: telefono || null,
email: email || null,
cuit: cuit || null,
observaciones: obs || null,
};
const res = await window.API.put(`/clientes/${cliente.id}`, body);
setLoading(false);
if (res.data?.id || res.id) {
window.toast(`Cliente "${nombre}" actualizado`);
onSuccess && onSuccess();
} else {
const msgs = res.errors ? Object.values(res.errors).flat().join(" · ") : res.message;
setErr(msgs || "Error al guardar.");
}
};
const eliminar = async () => {
await window.API.del(`/clientes/${cliente.id}`);
window.toast(`"${cliente.nombre}" eliminado`);
onSuccess && onSuccess();
};
const TabBtnC = ({ id, label, badge }) => (
);
const estadoBadge = (estado) => {
const cls = estado === "pagado" ? "b-green" : estado === "parcial" ? "b-gold" : "b-bad";
const lbl = estado === "pagado" ? "Pagado" : estado === "parcial" ? "Parcial" : "Pendiente";
return {lbl};
};
const saldo = historial.cliente?.saldo_pendiente ?? cliente.saldo ?? 0;
const total = historial.cliente?.total_facturado ?? cliente.total ?? 0;
return (
e.stopPropagation()}
style={{ maxWidth:900, width:"95vw", maxHeight:"90vh", display:"flex", flexDirection:"column" }}>
{cliente.nombre.split(" ").map(s=>s[0]).slice(0,2).join("")}
{cliente.tipo} {cliente.ciudad && cliente.ciudad !== "—" ? "· " + cliente.ciudad : ""}
{cliente.nombre}
{/* Resumen rápido */}
Cuenta corriente
0 ? "var(--bad)" : "var(--good)" }}>
{saldo > 0 ? D.fmtARS(saldo) : "Al día"}
Total facturado
{D.fmtARS(total)}
Ventas
{historial.ventas?.length || 0}
{tab === "datos" ? (
) : (
historial.ventas?.length === 0
?
Sin ventas registradas para este cliente.
:
| Fecha | Producto | Cant. | Monto | Pago | Estado | |
{historial.ventas.map(v => (
| {v.fecha} |
{v.producto} |
{v.cantidad} |
{D.fmtARS(v.monto)} |
{v.forma_pago?.replace("_", " ")} |
{estadoBadge(v.estado)} |
{v.estado !== "pagado" && (
)}
|
))}
)}
{pagoSel && (
setPagoSel(null)} style={{ zIndex:1100 }}>
e.stopPropagation()} style={{ maxWidth:400 }}>
Confirmar pago
¿Estás seguro que {cliente.nombre} entregó el dinero de esta venta?
Producto: {pagoSel.producto}
Fecha: {pagoSel.fecha}
Monto: {D.fmtARS(pagoSel.monto)}
Estado actual: {pagoSel.estado}
)}
);
}
function ModalNuevoCliente({ onClose, onSuccess }) {
const [nombre, setNombre] = useState("");
const [tipo, setTipo] = useState("tambo");
const [ciudad, setCiudad] = useState("");
const [telefono,setTelefono]= useState("");
const [email, setEmail] = useState("");
const [cuit, setCuit] = useState("");
const [obs, setObs] = useState("");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [err, setErr] = useState("");
const submit = async (e) => {
e.preventDefault();
setLoading(true); setErr("");
const res = await window.API.post("/clientes", {
nombre,
tipo,
ciudad: ciudad || null,
telefono: telefono || null,
email: email || null,
cuit: cuit || null,
observaciones:obs || null,
});
setLoading(false);
if (res.data?.id || res.id) {
setDone(true);
onSuccess && onSuccess();
setTimeout(onClose, 1400);
} else {
const msgs = res.errors ? Object.values(res.errors).flat().join(" ") : res.message;
setErr(msgs || "Error al guardar.");
}
};
return (
e.stopPropagation()}>
{done ? (
Cliente "{nombre}" guardado correctamente.
) : (
)}
);
}
function ModalNuevoServicio({ direccion, onClose, onSuccess }) {
const D = window.DATA;
const tiposSrv = direccion === "prestado"
? ["Siembra de alfalfa","Corte y rastrillado","Enfardado redondo","Enfardado prismático","Megaenfardado","Otro"]
: ["Fumigación","Pulverización","Cosecha","Transporte","Reparación maquinaria","Veterinario","Otro"];
const [contraparte, setContraparte] = useState("");
const [servicio, setServicio] = useState(tiposSrv[0]);
const [hectareas, setHectareas] = useState("");
const [fecha, setFecha] = useState(new Date().toISOString().slice(0,10));
const [monto, setMonto] = useState("");
const [estado, setEstado] = useState("pendiente");
const [obs, setObs] = useState("");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const submit = async (e) => {
e.preventDefault();
setErr(""); setLoading(true);
const res = await window.API.post("/servicios", {
direccion, contraparte, servicio,
hectareas: hectareas ? parseFloat(hectareas) : null,
fecha, monto: parseFloat(monto) || 0, estado,
observaciones: obs || null,
});
setLoading(false);
if (res.data?.id || res.id) { window.toast("Servicio registrado"); onSuccess(); onClose(); }
else { setErr(res.message || "Error al guardar."); }
};
return (
e.stopPropagation()} style={{ maxWidth:520 }}>
{direccion === "prestado" ? "Nuevo servicio prestado" : "Nuevo servicio contratado"}
);
}
function ServiciosTerceros() {
const D = window.DATA;
const [tab, setTab] = useState("prestado");
const [showNuevo, setShowNuevo] = useState(false);
const [servicios, setServicios] = useState([]);
const [resumen, setResumen] = useState({ prestados: { total:0, cobrado:0, pendiente:0, count:0 }, contratados: { total:0, pagado:0, pendiente:0, count:0 } });
const recargar = () => {
window.API.get("/servicios").then(r => { if (r.data) setServicios(r.data); });
window.API.get("/servicios-resumen").then(r => { if (r.prestados) setResumen(r); });
};
useEffect(() => { recargar(); }, []);
const listado = servicios.filter(s => s.direccion === tab);
const estatusLabel = (estado, dir) => {
if (estado === "completado") return dir === "prestado" ? "Cobrado" : "Pagado";
if (estado === "parcial") return "Parcial";
return "Pendiente";
};
const estatusCls = (estado) => estado === "completado" ? "b-green" : estado === "parcial" ? "b-gold" : "b-bad";
const totalHa = listado.reduce((s, r) => s + (r.hectareas || 0), 0);
const stats = tab === "prestado" ? resumen.prestados : resumen.contratados;
const colorAccion = tab === "prestado" ? "var(--good)" : "var(--bad)";
const labelAccion = tab === "prestado" ? "Cobrado" : "Pagado";
const labelPendiente = tab === "prestado" ? "A cobrar" : "A pagar";
const valorAccion = tab === "prestado" ? stats.cobrado : stats.pagado;
const eliminarServicio = async (id) => {
if (!confirm("¿Eliminar este servicio?")) return;
await window.API.del(`/servicios/${id}`);
window.toast("Servicio eliminado");
recargar();
};
const marcarCompletado = async (id) => {
await window.API.patch(`/servicios/${id}`, { estado: "completado" });
window.toast(tab === "prestado" ? "Marcado como cobrado" : "Marcado como pagado");
recargar();
};
return (
Servicios a terceros
Servicios que damos a clientes y servicios que contratamos a terceros
{/* Tabs */}
0 ? totalHa.toFixed(0) + " ha" : "—"} delta="" deltaDir="info" hint="acumuladas" />
{tab === "prestado" ? "Servicios prestados" : "Servicios contratados"}
{listado.length} registros
| {tab === "prestado" ? "Cliente" : "Proveedor"} |
Servicio |
Has |
Fecha |
Monto |
Estado |
|
{listado.length === 0 && (
|
Sin servicios {tab === "prestado" ? "prestados" : "contratados"} registrados.
|
)}
{listado.map(s => (
| {s.contraparte} |
{s.servicio} |
{s.hectareas ? s.hectareas : "—"} |
{s.fecha} |
{D.fmtARS(s.monto)} |
{estatusLabel(s.estado, s.direccion)} |
{s.estado !== "completado" && (
)}
|
))}
{showNuevo &&
setShowNuevo(false)} onSuccess={recargar} />}
);
}
/* ============================================
Gastos y costos
============================================ */
/* ── Configuración de categorías de gastos ── */
const CATS_GASTO = [
{ key: "combustible", label: "Combustible", icon: "Fuel", color: "#2d5016" },
{ key: "mantenimiento", label: "Mantenimiento", icon: "Wrench", color: "#3b82f6" },
{ key: "insumos", label: "Insumos", icon: "Package", color: "#d4a574" },
{ key: "personal", label: "Personal", icon: "Users", color: "#f59e0b" },
{ key: "servicios", label: "Servicios", icon: "Zap", color: "#8b5cf6" },
{ key: "logistica", label: "Logística", icon: "Truck", color: "#0891b2" },
{ key: "compra_fardos", label: "Compra de fardos", icon: "Building2", color: "#8b6f47" },
{ key: "uso_personal", label: "Uso personal", icon: "UserCircle", color: "#ef4444" },
{ key: "otros", label: "Otros", icon: "Receipt", color: "#6b7280" },
];
function Gastos() {
const D = window.DATA;
const [gastos, setGastos] = useState([]);
const [catSel, setCatSel] = useState("todas");
const [tabModo, setTabModo] = useState("gastos");
const [showNuevo, setShowNuevo] = useState(false);
const [gastoSel, setGastoSel] = useState(null); // gasto abierto en detalle/edición
const [loading, setLoading] = useState(true);
const recargar = () => {
setLoading(true);
window.API.get("/gastos").then(r => {
if (r.data) setGastos(r.data);
setLoading(false);
}).catch(() => setLoading(false));
};
useEffect(() => { recargar(); }, []);
// ── totales ──────────────────────────────────────────────────────
const gastosConMonto = gastos.filter(g => g.monto != null);
const totalGeneral = gastosConMonto.reduce((s, g) => s + g.monto, 0);
const totalPorCat = (key) =>
gastosConMonto.filter(g => g.categoria === key).reduce((s, g) => s + g.monto, 0);
// gastos filtrados para la tabla
const gastosBase = gastos.filter(g => g.categoria !== "uso_personal");
const gastosPersonal = gastos.filter(g => g.categoria === "uso_personal");
const gastosFiltrados = catSel === "todas"
? gastosBase
: gastosBase.filter(g => g.categoria === catSel);
const totalFiltrado = gastosFiltrados.filter(g => g.monto).reduce((s, g) => s + g.monto, 0);
const totalPersonal = gastosPersonal.filter(g => g.monto).reduce((s, g) => s + g.monto, 0);
// datos para el gráfico de torta
const pieData = CATS_GASTO.map(c => ({
cat: c.label,
monto: totalPorCat(c.key),
color: c.color,
})).filter(c => c.monto > 0);
return (
Gastos y costos
{gastos.length} movimientos · total {D.fmtARS(totalGeneral)}
{gastosPersonal.length > 0 && ` · uso personal ${D.fmtARS(totalPersonal)}`}
{/* ── Tabs: Gastos / Uso personal ── */}
{[["gastos", "Gastos generales"], ["personal", "Uso personal"]].map(([k, l]) => (
))}
{/* ════════════════════════════════════════════════════════════
TAB: GASTOS GENERALES
════════════════════════════════════════════════════════════ */}
{tabModo === "gastos" && (
<>
{/* KPI por categoría (excluyendo uso_personal) */}
{/* Card "Todas" */}
setCatSel("todas")} style={{
cursor: "pointer",
borderColor: catSel === "todas" ? "var(--green-700)" : undefined,
boxShadow: catSel === "todas" ? "0 0 0 2px var(--green-100)" : undefined,
transition: "all 140ms",
}}>
Todas
{D.fmtARS(gastosBase.filter(g => g.monto).reduce((s,g) => s + g.monto, 0))}
{gastosBase.length} movimientos
{/* Card por categoría */}
{CATS_GASTO.filter(c => c.key !== "uso_personal").map(c => {
const total = totalPorCat(c.key);
const cant = gastos.filter(g => g.categoria === c.key).length;
const isActive = catSel === c.key;
return (
setCatSel(c.key)} style={{
cursor: "pointer",
borderColor: isActive ? c.color : undefined,
boxShadow: isActive ? `0 0 0 2px ${c.color}30` : undefined,
transition: "all 140ms",
}}>
{c.label}
{D.fmtARS(total)}
{cant} mov.
);
})}
{/* Gráfico + tabla */}
{pieData.length > 0 && (
{pieData.map((e, i) => )}
[D.fmtARS(v), ""]}
/>
)}
{/* Tabla detalle */}
{catSel === "todas" ? "Todos los gastos" : `Gastos — ${CATS_GASTO.find(c=>c.key===catSel)?.label}`}
{gastosFiltrados.length} movimientos · {D.fmtARS(totalFiltrado)}
{CATS_GASTO.filter(c => c.key !== "uso_personal").map(c => (
))}
| Fecha | Categoría | Detalle | Proveedor / A quién | Método | Monto | Estado |
{loading && (
| Cargando... |
)}
{!loading && gastosFiltrados.length === 0 && (
|
No hay gastos registrados.{catSel === "todas" ? ' Usá "Cargar gasto" para agregar.' : " Cambiá el filtro de categoría."}
|
)}
{gastosFiltrados.map((g, i) => {
const cat = CATS_GASTO.find(c => c.key === g.categoria);
return (
setGastoSel(g)}
onMouseEnter={e => e.currentTarget.style.background = "var(--green-50)"}
onMouseLeave={e => e.currentTarget.style.background = ""}>
| {g.fecha} |
{cat?.label || g.categoria}
|
{g.detalle} |
{g.proveedor_persona || "—"} |
{g.metodo_pago || "—"} |
{g.monto != null ? D.fmtARS(g.monto) : —} |
{g.estado}
|
);
})}
{gastosFiltrados.length > 0 && totalFiltrado > 0 && (
| Subtotal |
{D.fmtARS(totalFiltrado)} |
|
)}
>
)}
{/* ════════════════════════════════════════════════════════════
TAB: USO PERSONAL
════════════════════════════════════════════════════════════ */}
{tabModo === "personal" && (
<>
{/* Banner resumen uso personal */}
Total uso personal
{D.fmtARS(totalPersonal)}
{gastosPersonal.length} {gastosPersonal.length === 1 ? "movimiento registrado" : "movimientos registrados"}
{/* Tabla uso personal */}
Detalle de uso personal
En qué se usó el dinero · registros completos
| Fecha | Persona | En qué se gastó | Método | Monto | Estado |
{gastosPersonal.length === 0 && (
|
Sin registros de uso personal. Usá "Registrar uso personal" para agregar.
|
)}
{gastosPersonal.map((g, i) => (
setGastoSel(g)}
onMouseEnter={e => e.currentTarget.style.background = "var(--green-50)"}
onMouseLeave={e => e.currentTarget.style.background = ""}>
| {g.fecha} |
{g.proveedor_persona || "—"}
|
{g.detalle} |
{g.metodo_pago || "—"} |
{g.monto != null ? D.fmtARS(g.monto) : "—"} |
{g.estado} |
))}
{gastosPersonal.length > 0 && totalPersonal > 0 && (
| Total uso personal |
{D.fmtARS(totalPersonal)} |
|
)}
>
)}
{showNuevo && (
setShowNuevo(false)}
onSuccess={() => { recargar(); setShowNuevo(false); }}
/>
)}
{gastoSel && (
setGastoSel(null)}
onSuccess={() => { recargar(); setGastoSel(null); }}
/>
)}
);
}
/* ── Modal: ver detalle, editar o eliminar un gasto ── */
function ModalDetalleGasto({ gasto, onClose, onSuccess }) {
const D = window.DATA;
const [modo, setModo] = useState("ver"); // "ver" | "editar"
const [confirmar, setConfirmar] = useState(false);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState("");
// Campos editables
const [categoria, setCategoria] = useState(gasto.categoria);
const [detalle, setDetalle] = useState(gasto.detalle);
const [fecha, setFecha] = useState(gasto.fecha || "");
const [monto, setMonto] = useState(gasto.monto != null ? String(gasto.monto) : "");
const [persona, setPersona] = useState(gasto.proveedor_persona || "");
const [metodo, setMetodo] = useState(gasto.metodo_pago || "Transferencia");
const [estado, setEstado] = useState(gasto.estado || "pagado");
const [obs, setObs] = useState(gasto.observaciones || "");
const esPersonal = categoria === "uso_personal";
const cat = CATS_GASTO.find(c => c.key === categoria);
const toISO = (str) => {
const [d, m, y] = (str || "").split("/");
return y ? `${y}-${m}-${d}` : str; // si ya es ISO lo deja
};
const guardar = async (e) => {
e.preventDefault();
setLoading(true); setErr("");
const res = await window.API.put(`/gastos/${gasto.id}`, {
fecha: toISO(fecha),
categoria,
detalle: detalle.trim(),
proveedor_persona: persona || null,
metodo_pago: metodo || null,
monto: monto ? parseFloat(monto) : null,
estado,
observaciones: obs || null,
});
setLoading(false);
if (res.data?.id || res.id) {
onSuccess && onSuccess();
} else {
const msgs = res.errors ? Object.values(res.errors).flat().join(" ") : res.message;
setErr(msgs || "Error al guardar.");
}
};
const eliminar = async () => {
setLoading(true);
const res = await window.API.del(`/gastos/${gasto.id}`);
setLoading(false);
if (res.mensaje || res.message) {
onSuccess && onSuccess();
} else {
setErr("No se pudo eliminar.");
setConfirmar(false);
}
};
return (
e.stopPropagation()} style={{ maxWidth: 540 }}>
{/* Header */}
{cat?.label || gasto.categoria}
{gasto.detalle}
{/* ══ MODO VER ══ */}
{modo === "ver" && (
<>
{/* Box principal con monto */}
Monto
{gasto.monto != null ? D.fmtARS(gasto.monto) : Sin monto}
{gasto.estado === "pagado" ? "Pagado" : "Pendiente"}
{/* Grilla de datos */}
{[
{ label: "Fecha", val: gasto.fecha },
{ label: "Método de pago", val: gasto.metodo_pago || "—" },
{ label: esPersonal ? "Persona" : "Proveedor / beneficiario",
val: gasto.proveedor_persona || "—" },
{ label: "Categoría", val: cat?.label || gasto.categoria },
].map(({ label, val }) => (
))}
{gasto.observaciones && (
Observaciones
{gasto.observaciones}
)}
{/* Confirmación de eliminación */}
{confirmar ? (
¿Seguro que querés eliminar este gasto?
Esta acción mueve el registro a la papelera. Se puede recuperar desde el panel admin.
) : null}
{err &&
{err}
}
>
)}
{/* ══ MODO EDITAR ══ */}
{modo === "editar" && (
)}
);
}
/* ── Modal para cargar cualquier gasto ── */
function ModalNuevoGasto({ defaultCat, onClose, onSuccess }) {
const D = window.DATA;
const [categoria, setCategoria] = useState(defaultCat || "combustible");
const [detalle, setDetalle] = useState("");
const [fecha, setFecha] = useState(new Date().toLocaleDateString("es-AR"));
const [monto, setMonto] = useState("");
const [persona, setPersona] = useState("");
const [proveedor, setProveedor] = useState("");
const [metodo, setMetodo] = useState("Transferencia");
const [estado, setEstado] = useState("pagado");
const [obs, setObs] = useState("");
// Campos específicos de cheque recibido
const [chequeNum, setChequeNum] = useState("");
const [chequeBanco, setChequeBanco] = useState("");
const [chequeSucursal,setChequeSucursal]= useState("");
const [chequeOrigen, setChequeOrigen] = useState(""); // quién lo libró (cliente)
const [chequeCuit, setChequeCuit] = useState(""); // CUIT del librador
const [chequeDestino, setChequeDestino] = useState(""); // a quién va (proveedor que lo cobra)
const [chequeCobro, setChequeCobro] = useState(""); // fecha de cobro / vencimiento
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [err, setErr] = useState("");
const esPersonal = categoria === "uso_personal";
const esFardo = categoria === "compra_fardos";
const esChequeRecibido= metodo === "Cheque recibido";
const toISO = (str) => {
if (!str) return null;
const parts = str.split("/");
if (parts.length === 3) return `${parts[2]}-${parts[1]}-${parts[0]}`;
return str; // si ya es ISO
};
const submit = async (e) => {
e.preventDefault();
setLoading(true); setErr("");
// 1. Guardar el gasto
const resGasto = await window.API.post("/gastos", {
fecha: toISO(fecha),
categoria,
detalle: detalle.trim(),
proveedor_persona: esPersonal ? (persona || null) : (proveedor || null),
metodo_pago: metodo || null,
monto: monto ? parseFloat(monto) : null,
estado,
observaciones: obs || null,
});
if (!resGasto.id) {
setLoading(false);
const msgs = resGasto.errors ? Object.values(resGasto.errors).flat().join(" ") : resGasto.message;
setErr(msgs || "Error al guardar el gasto.");
return;
}
// 2. Si el método es "Cheque recibido", también guardarlo en cheques
if (esChequeRecibido) {
await window.API.post("/cheques", {
tipo: "recibido",
numero: chequeNum || null,
banco: chequeBanco || null,
sucursal: chequeSucursal || null,
origen: chequeOrigen || null,
cuit: chequeCuit || null,
destino: chequeDestino || proveedor || null,
concepto: detalle.trim(),
monto: monto ? parseFloat(monto) : null,
fecha_recepcion: toISO(fecha),
fecha_cobro: toISO(chequeCobro) || null,
estado: chequeDestino || proveedor ? "endosado" : "en_cartera",
gasto_id: resGasto.id,
observaciones: obs || null,
});
}
setLoading(false);
setDone(true);
onSuccess && onSuccess();
};
const cat = CATS_GASTO.find(c => c.key === categoria);
return (
e.stopPropagation()}
style={{ maxHeight: "92dvh", display: "flex", flexDirection: "column" }}>
{esPersonal ? "Uso personal" : "Nuevo gasto"}
{esPersonal ? "Registrar uso personal" : "Cargar gasto"}
{done ? (
Gasto registrado correctamente.
) : (
)}
);
}
/* ── Etiquetas de categorías ── */
const CAT_INGRESO = {
venta_fardos: { label: "Venta de fardos", icon: "Package", color: "#2d5016" },
servicios_terceros: { label: "Servicios a terceros", icon: "Tractor", color: "#0891b2" },
asociacion: { label: "Asociación", icon: "Handshake", color: "#8b6f47" },
otros: { label: "Otros ingresos", icon: "Receipt", color: "#6b7280" },
};
/* ============================================
Rentabilidad
============================================ */
function Rentabilidad() {
const D = window.DATA;
const [resumen, setResumen] = useState(null);
const [ingresosList,setIngresosList]= useState([]);
const [showNuevo, setShowNuevo] = useState(false);
const [ingresoSel, setIngresoSel] = useState(null);
const [tab, setTab] = useState("resumen"); // "resumen" | "ingresos"
const recargar = () => {
window.API.get("/rentabilidad-resumen").then(r => { if (!r.error) setResumen(r); });
window.API.get("/ingresos").then(r => { if (r.data) setIngresosList(r.data); });
};
useEffect(() => { recargar(); }, []);
const totalIngresos = resumen?.total_ingresos || 0;
const totalGastos = resumen?.total_gastos || 0;
const utilidad = totalIngresos - totalGastos;
const margen = totalIngresos > 0 ? ((utilidad / totalIngresos) * 100).toFixed(1) : 0;
// Datos del gráfico por mes
const chartData = (resumen?.por_mes || []).map(m => ({
mes: m.mes.slice(5) + "/" + m.mes.slice(2,4), // "05/26"
Ingresos: m.total_ingresos,
Gastos: m.total_gastos,
}));
// Desglose: ingresos por categoría vs gastos por categoría
const ingCat = resumen?.ingresos_por_cat || [];
const gastCat = resumen?.gastos_por_cat || [];
return (
Rentabilidad
Ingresos y costos reales · acumulado total
{/* Tabs */}
{[["resumen","Resumen"],["ingresos","Ingresos cargados"]].map(([k,l]) => (
))}
{/* ── TAB RESUMEN ── */}
{tab === "resumen" && (
<>
{/* KPIs principales */}
Ingresos totales
{D.fmtARS(totalIngresos)}
{ingresosList.length} registros cargados
Costos totales
{D.fmtARS(totalGastos)}
desde módulo Gastos
= 0 ? "var(--green-900)" : "var(--bad)", color: "#fff" }}>
Utilidad neta
{D.fmtARS(utilidad)}
= 0 ? "var(--gold-300)" : "#fca5a5" }}>
Margen {margen}%
{totalIngresos === 0 && totalGastos === 0 ? (
Sin datos todavía. Registrá ingresos con el botón "Registrar ingreso" y cargá gastos en el módulo de Gastos.
) : (
{/* Gráfico ingresos vs costos por mes */}
{chartData.length > 0 ? (
`$${(v/1e6).toFixed(1)}M`} width={55} />
[D.fmtARS(v), ""]}
/>
) : (
Cargá ingresos de distintos meses para ver el gráfico.
)}
{/* Desglose costos vs ingresos por categoría */}
{ingCat.length > 0 && (
<>
Ingresos
{ingCat.map(c => {
const info = CAT_INGRESO[c.categoria] || { label: c.categoria, color: "#888", icon: "Receipt" };
const pct = totalIngresos > 0 ? (c.total / totalIngresos * 100).toFixed(0) : 0;
return (
{info.label}
{D.fmtARS(c.total)} ({pct}%)
);
})}
>
)}
{gastCat.length > 0 && (
<>
Costos
{gastCat.map(c => {
const pct = totalGastos > 0 ? (c.total / totalGastos * 100).toFixed(0) : 0;
return (
{c.categoria}
{D.fmtARS(c.total)} ({pct}%)
);
})}
>
)}
{ingCat.length === 0 && gastCat.length === 0 && (
Sin datos aún.
)}
)}
>
)}
{/* ── TAB INGRESOS CARGADOS ── */}
{tab === "ingresos" && (
Ingresos registrados
{ingresosList.length} registros · total {D.fmtARS(totalIngresos)}
{ingresosList.length === 0 ? (
Sin ingresos registrados. Usá "Registrar ingreso" para agregar.
) : (
| Período | Categoría | Descripción | Monto | |
{ingresosList.map(i => {
const info = CAT_INGRESO[i.categoria] || { label: i.categoria, color: "#888", icon: "Receipt" };
return (
setIngresoSel(i)}
onMouseEnter={e => e.currentTarget.style.background = "var(--green-50)"}
onMouseLeave={e => e.currentTarget.style.background = ""}>
| {i.periodo_label || i.periodo} |
{info.label}
|
{i.descripcion || "—"} |
{D.fmtARS(i.monto)} |
|
);
})}
)}
)}
{showNuevo &&
setShowNuevo(false)} onSuccess={() => { recargar(); setShowNuevo(false); }} />}
{ingresoSel && setIngresoSel(null)} onSuccess={() => { recargar(); setIngresoSel(null); }} />}
);
}
/* ── Modal: registrar ingreso mensual ── */
function ModalNuevoIngreso({ onClose, onSuccess }) {
const [periodo, setPeriodo] = useState(() => {
const hoy = new Date();
return `${hoy.getFullYear()}-${String(hoy.getMonth()+1).padStart(2,"0")}`;
});
const [monto, setMonto] = useState("");
const [categoria, setCategoria] = useState("venta_fardos");
const [descripcion,setDescripcion]= useState("");
const [obs, setObs] = useState("");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [err, setErr] = useState("");
const D = window.DATA;
const submit = async (e) => {
e.preventDefault();
setLoading(true); setErr("");
const res = await window.API.post("/ingresos", {
periodo: `${periodo}-01`, // primer día del mes
monto: parseFloat(monto),
categoria,
descripcion: descripcion || null,
observaciones:obs || null,
});
setLoading(false);
if (res.data?.id || res.id) {
setDone(true);
onSuccess && onSuccess();
} else {
const msgs = res.errors ? Object.values(res.errors).flat().join(" ") : res.message;
setErr(msgs || "Error al guardar.");
}
};
const info = CAT_INGRESO[categoria];
return (
e.stopPropagation()}>
Nuevo ingreso
Registrar ingreso del período
{done ? (
Ingreso registrado correctamente.
) : (
)}
);
}
/* ── Modal: editar / eliminar ingreso ── */
function ModalEditarIngreso({ ingreso, onClose, onSuccess }) {
const D = window.DATA;
const [modo, setModo] = useState("ver");
const [confirmar, setConfirmar] = useState(false);
const [monto, setMonto] = useState(String(ingreso.monto));
const [categoria, setCategoria] = useState(ingreso.categoria);
const [descripcion,setDescripcion]= useState(ingreso.descripcion || "");
const [loading, setLoading] = useState(false);
const [err, setErr] = useState("");
const info = CAT_INGRESO[ingreso.categoria] || { label: ingreso.categoria, color: "#888" };
const guardar = async (e) => {
e.preventDefault();
setLoading(true); setErr("");
const res = await window.API.put(`/ingresos/${ingreso.id}`, {
periodo: `${ingreso.periodo}-01`,
monto: parseFloat(monto),
categoria,
descripcion:descripcion || null,
});
setLoading(false);
if (res.data?.id || res.id) onSuccess && onSuccess();
else setErr(res.message || "Error al guardar.");
};
const eliminar = async () => {
setLoading(true);
await window.API.del(`/ingresos/${ingreso.id}`);
setLoading(false);
onSuccess && onSuccess();
};
return (
e.stopPropagation()}>
{info.label}
{ingreso.periodo_label || ingreso.periodo}
{modo === "ver" ? (
<>
Monto registrado
{D.fmtARS(ingreso.monto)}
{ingreso.descripcion && (
{ingreso.descripcion}
)}
{confirmar ? (
¿Eliminar este ingreso?
) : null}
>
) : (
)}
);
}
/* ============================================
Maquinaria
============================================ */
function Maquinaria() {
const D = window.DATA;
const [showService, setShowService] = useState(false);
const [showNuevaMaq, setShowNuevaMaq] = useState(false);
const [showUso, setShowUso] = useState(null); // maquina para registrar uso
const [detail, setDetail] = useState(null);
const [showFuel, setShowFuel] = useState(null);
const [MAQUINAS, setMAQUINAS] = useState([]);
const [LOTES_MAQUINAS, setLOTES_MAQUINAS] = useState([]);
const recargarMaquinas = () => {
window.API.get("/maquinas").then(r => {
if (r.data) setMAQUINAS(r.data.map(window.API_MAP.maquina));
});
};
useEffect(() => {
recargarMaquinas();
window.API.get("/lotes").then(r => {
if (r.data) setLOTES_MAQUINAS(r.data.map(l => l.nombre));
});
}, []);
const totalLitrosMes = MAQUINAS.reduce((s, m) => s + (m.cargas || []).filter(c => c.fecha && c.fecha.endsWith("/05/2026")).reduce((a, c) => a + c.litros, 0), 0);
const totalGastoMes = MAQUINAS.reduce((s, m) => s + (m.cargas || []).filter(c => c.fecha && c.fecha.endsWith("/05/2026")).reduce((a, c) => a + (c.costo || 0), 0), 0);
return (
Maquinaria
{MAQUINAS.length} unidades en flota · tocá una para ver combustible e historial
{/* hidden — botón legacy removido */}
m.estado!=='service').length} / ${MAQUINAS.length}`} delta="" deltaDir="info" hint="unidades en operación" />
{MAQUINAS.length === 0 && (
No hay máquinas cargadas. Usá "Nueva máquina" para agregar equipos a la flota.
)}
{MAQUINAS.map(m => {
const pct = (m.serviceUsado / m.serviceTotal) * 100;
const barClass = pct >= 100 ? "bad" : pct >= 75 ? "warn" : "";
const icon = m.tipo === "Tractor" ? "Tractor" : m.tipo === "Logística" ? "Truck" : m.tipo === "Segadora" ? "Scissors" : m.tipo === "Rastrillo" ? "Rake" : m.tipo === "Sembradora" ? "Sprout" : "Cog";
return (
setDetail(m)}>
{m.nombre}
{m.tipo} · año {m.año}
Uso acumulado
{m.km ? `${D.fmtNum(m.km)} km` : `${D.fmtNum(m.horas)} hs`}
Próximo service
{m.proxService}
{m.ultCarga && (
Última carga
{m.ultCarga.fecha} · {m.ultCarga.litros} L · {D.fmtARS(m.ultCarga.costo)}
)}
Último uso
{m.usos[0] ? `${m.usos[0].fecha} · ${m.usos[0].lote}` : "—"}
Ciclo de service
{Math.min(100, Math.round(pct))}%
);
})}
{showNuevaMaq &&
setShowNuevaMaq(false)} onSuccess={recargarMaquinas} />}
{showUso && setShowUso(null)} onSuccess={recargarMaquinas} />}
{/* DETALLE de máquina */}
{detail && setDetail(null)} onLoadFuel={(m) => { setDetail(null); setShowFuel(m); }} onRegistrarUso={(m) => { setDetail(null); setShowUso(m); }} />}
{/* MODAL cargar combustible */}
{showFuel && setShowFuel(null)} onSuccess={recargarMaquinas} />}
{/* MODAL programar service */}
{showService && (
setShowService(false)}>
e.stopPropagation()}>
Programar service
)}
);
}
/* Detalle de una máquina — combustible + uso */
function MaquinaDetail({ maquina, onClose, onLoadFuel, onRegistrarUso }) {
const D = window.DATA;
const m = maquina;
const [tab, setTab] = useState("uso");
const [usos, setUsos] = useState(m.usos || []);
useEffect(() => {
if (!m.id) return;
window.API.get("/usos-maquina").then(r => {
if (r.data) {
const propios = r.data
.filter(u => u.maquina_id === m.id)
.map(u => ({
fecha: u.fecha,
lote: u.lote || u.observaciones || "—",
tarea: u.tarea,
horas: u.horas,
km: u.km,
}));
if (propios.length > 0) setUsos(propios);
}
});
}, [m.id]);
const totalLitros = (m.cargas || []).reduce((s, c) => s + c.litros, 0);
const totalGasto = (m.cargas || []).reduce((s, c) => s + c.costo, 0);
return (
e.stopPropagation()} style={{ maxWidth: 720, padding: 0, overflow: "hidden" }}>
{/* Header */}
{m.nombre}
{m.tipo} · año {m.año} · {m.km ? D.fmtNum(m.km) + " km" : D.fmtNum(m.horas) + " hs"}
{/* Tabs */}
setTab("uso")} label="Historial de uso" count={m.usos.length} />
setTab("combustible")} label="Combustible" count={(m.cargas || []).length} disabled={m.ultCarga === null} />
{/* Body */}
{tab === "uso" && (
<>
{usos.length === 0 ? (
Sin registros de uso aún.
) : (
{usos.map((u, i) => (
{u.fecha}
{u.tarea}
{u.lote}
{i === 0 && último uso}
{u.km ? u.km : u.horas}
{u.km ? "km" : "horas"}
))}
)}
>
)}
{tab === "combustible" && (
<>
{(m.cargas || []).length === 0 ? (
Esta máquina no consume gasoil directamente.
) : (
<>
{/* Resumen */}
Última carga
{m.ultCarga.fecha}
{m.ultCarga.litros} L · {D.fmtARS(m.ultCarga.costo)}
Total cargado
{D.fmtNum(totalLitros)} L
Total gastado
{D.fmtARS(totalGasto)}
{/* Lista */}
Cargas registradas
| Fecha | Surtidor | Litros | Costo | $/L |
{m.cargas.map((c, i) => (
| {c.fecha} |
{c.surtidor} |
{c.litros} L |
{D.fmtARS(c.costo)} |
{D.fmtARS(Math.round(c.costo / c.litros))} |
))}
>
)}
>
)}
{/* Footer */}
{m.ultCarga !== null && (
)}
);
}
function DetailTab({ active, onClick, label, count, disabled }) {
return (
);
}
/* Modal: cargar combustible */
function CombustibleModal({ maquina, maquinas = [], onClose, onSuccess }) {
const D = window.DATA;
const [maqSel, setMaqSel] = useState(maquina.nombre);
const [litros, setLitros] = useState(180);
const [costo, setCosto] = useState(0);
const [fecha, setFecha] = useState(new Date().toISOString().slice(0,10));
const [surtidor, setSurtidor] = useState("Cisterna propia");
const [surtOtro, setSurtOtro] = useState("");
const [done, setDone] = useState(false);
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const submit = async (e) => {
e.preventDefault();
setErr(""); setLoading(true);
const maq = maquinas.find(m => m.nombre === maqSel) || maquina;
const surtFinal = surtidor === "Otro" ? (surtOtro || "Otro") : surtidor;
const res = await window.API.post("/cargas-combustible", {
maquina_id: maq.id,
fecha,
litros,
surtidor: surtFinal,
});
setLoading(false);
if (res.data?.id || res.id) {
setDone(true);
onSuccess && onSuccess();
setTimeout(onClose, 1400);
} else {
const msgs = res.errors ? Object.values(res.errors).flat().join(" · ") : (res.message || "Error al guardar.");
setErr(msgs);
}
};
const maqsConCombustible = maquinas.filter(m => m.consume_combustible !== false);
return (
e.stopPropagation()}>
Carga de combustible
Registrar nueva carga
);
}
/* ============================================================
MODAL: Nueva máquina
============================================================ */
function ModalNuevaMaquina({ onClose, onSuccess }) {
const [nombre, setNombre] = useState("");
const [tipo, setTipo] = useState("Tractor");
const [anio, setAnio] = useState(new Date().getFullYear().toString());
const [estado, setEstado] = useState("operativo");
const [medicion, setMedicion] = useState("horas");
const [usoAcum, setUsoAcum] = useState("0");
const [servTotal, setServTotal] = useState("");
const [servUsado, setServUsado] = useState("0");
const [proxServ, setProxServ] = useState("");
const [combustible,setCombustible]= useState(true);
const [obs, setObs] = useState("");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [err, setErr] = useState("");
const submit = async (e) => {
e.preventDefault();
setLoading(true); setErr("");
const res = await window.API.post("/maquinas", {
nombre,
tipo,
anio: parseInt(anio),
estado,
medicion,
uso_acumulado: parseInt(usoAcum) || 0,
service_total: servTotal ? parseInt(servTotal) : null,
service_usado: parseInt(servUsado) || 0,
proximo_service: proxServ || null,
alerta_service: false,
consume_combustible: combustible,
observaciones: obs || null,
});
setLoading(false);
if (res.data?.id || res.id) {
setDone(true);
onSuccess && onSuccess();
setTimeout(onClose, 1400);
} else {
const msgs = res.errors ? Object.values(res.errors).flat().join(" ") : res.message;
setErr(msgs || "Error al guardar.");
}
};
const tiposIcono = { Tractor: "Tractor", Segadora: "Scissors", Enfardadora: "Package", Megaenfardadora: "Package", Rastrillo: "Rake", Sembradora: "Sprout", Logística: "Truck", Otro: "Cog" };
return (
e.stopPropagation()}>
Agregar máquina a la flota
{done ? (
"{nombre}" agregada a la flota.
) : (
)}
);
}
/* ============================================================
MODAL: Registrar uso de máquina
============================================================ */
function ModalRegistrarUso({ maquina, lotes, onClose, onSuccess }) {
const D = window.DATA;
const [loteNombre, setLoteNombre] = useState("");
const [tarea, setTarea] = useState("");
const [fecha, setFecha] = useState(new Date().toLocaleDateString("es-AR"));
const [horas, setHoras] = useState("");
const [km, setKm] = useState("");
const [obs, setObs] = useState("");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [err, setErr] = useState("");
const toISO = (str) => {
const [d, m, y] = (str || "").split("/");
return y ? `${y}-${m}-${d}` : null;
};
const submit = async (e) => {
e.preventDefault();
setLoading(true); setErr("");
const res = await window.API.post("/usos-maquina", {
maquina_id: maquina.id || null,
lote_id: null, // por nombre, no ID — se puede mejorar
fecha: toISO(fecha),
tarea,
horas: maquina.medicion === "horas" && horas ? parseFloat(horas) : null,
km: maquina.medicion === "km" && km ? parseInt(km) : null,
observaciones: obs || null,
});
setLoading(false);
if (res.data?.id || res.id) {
setDone(true);
onSuccess && onSuccess();
setTimeout(onClose, 1400);
} else {
const msgs = res.errors ? Object.values(res.errors).flat().join(" ") : res.message;
setErr(msgs || "Error al guardar.");
}
};
return (
e.stopPropagation()}>
Registrar uso
{maquina.nombre}
{done ? (
Uso registrado en {maquina.nombre}.
) : (
)}
);
}
Object.assign(window, { Produccion, Stock, VentasModulo, ServiciosTerceros, Gastos, Rentabilidad, Maquinaria });