import React, { useState, useEffect, useMemo, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import {
LayoutDashboard, Package, PackagePlus, ClipboardList, Users, Wrench,
Building2, BarChart3, Settings, ArrowLeft, Plus, Search, X, Upload,
Download, FileText, Trash2, Edit2, Check, AlertTriangle, Image as ImageIcon,
ChevronLeft, Save, RotateCcw, Loader2, Printer, Ban, FileSpreadsheet,
ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Inbox,
} from 'lucide-react';
const DB = window.InvDB;
/* ============================================================================
Utilidades y componentes chicos reutilizados en todas las pantallas
============================================================================ */
function clsx(...xs) { return xs.filter(Boolean).join(' '); }
function useToasts() {
const [toasts, setToasts] = useState([]);
function toast(mensaje, tipo = 'ok') {
const id = DB.uid();
setToasts((t) => [...t, { id, mensaje, tipo }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3200);
}
const ToastHost = () => (
{toasts.map((t) => (
{t.mensaje}
))}
);
return { toast, ToastHost };
}
function ConfirmModal({ abierto, titulo, mensaje, textoConfirmar = 'Confirmar', peligro, onConfirmar, onCancelar }) {
if (!abierto) return null;
return (
{mensaje}
Cancelar
{textoConfirmar}
);
}
function EmptyState({ icono: Icono, titulo, descripcion, accion }) {
return (
{titulo}
{descripcion &&
{descripcion}
}
{accion}
);
}
function Badge({ color, children }) {
return (
{children}
);
}
function EstadoStock({ stock, minimo }) {
let color = 'bg-green-100 text-green-700', texto = 'OK';
if (stock <= 0) { color = 'bg-red-100 text-red-700'; texto = 'Agotado'; }
else if (stock <= minimo) { color = 'bg-red-100 text-red-700'; texto = 'Bajo mínimo'; }
else if (stock <= minimo * 1.3) { color = 'bg-amber-100 text-amber-700'; texto = 'Por agotarse'; }
return {texto} ;
}
/* ============================================================================
Encabezado + barra lateral
============================================================================ */
const MODULOS = [
{ id: 'panel', label: 'Panel', icono: LayoutDashboard },
{ id: 'productos', label: 'Productos', icono: Package },
{ id: 'entradas', label: 'Entradas', icono: PackagePlus },
{ id: 'entregas', label: 'Entregas', icono: ClipboardList },
{ id: 'requisiciones', label: 'Requisiciones', icono: Inbox },
{ id: 'colaboradores', label: 'Colaboradores', icono: Users },
{ id: 'ajustes', label: 'Ajustes', icono: Wrench },
{ id: 'proveedores', label: 'Proveedores', icono: Building2 },
{ id: 'reportes', label: 'Reportes', icono: BarChart3 },
{ id: 'configuracion', label: 'Configuración', icono: Settings },
];
function Sidebar({ vista, irA, config }) {
return (
Ecocosecha
Inventario de Insumos
{MODULOS.map((m) => {
const Icono = m.icono;
const activo = vista === m.id;
return (
irA(m.id)}
className={clsx(
'w-full flex items-center gap-3 px-4 py-2.5 text-sm font-medium transition',
activo ? 'bg-green-800 text-white border-r-4 border-amber-400' : 'text-green-200 hover:bg-green-800/60 hover:text-white'
)}
>
{m.label}
);
})}
);
}
/* ============================================================================
Pantalla de bienvenida (primera vez: semilla o vacío)
============================================================================ */
function PantallaBienvenida({ onListo, toast }) {
const [cargando, setCargando] = useState(false);
async function conSemilla() {
setCargando(true);
try {
const { entrada } = await DB.cargarSemilla();
toast(`Catálogo de ejemplo cargado · Entrada ${entrada.consecutivo} por ${DB.money(entrada.total)}`);
onListo();
} catch (e) {
toast('No se pudo cargar la semilla: ' + e.message, 'error');
setCargando(false);
}
}
async function sinSemilla() {
setCargando(true);
await DB.iniciarVacio();
toast('Listo, empezaste en limpio.');
onListo();
}
return (
Inventario de Insumos
Primera vez que abres esta herramienta en este navegador. ¿Cómo quieres empezar?
{cargando ? : }
Cargar catálogo de ejemplo (20 productos + 1 entrada)
Empezar en limpio
Todo se guarda en este navegador (IndexedDB). Puedes exportar un respaldo desde Configuración.
);
}
/* ============================================================================
Panel principal (versión Fase 1 — tarjetas y alertas; gráficas en Fase 4)
============================================================================ */
function Panel({ productos, categorias, irA }) {
const valorInventario = productos.reduce((s, p) => s + (p.stockActual || 0) * (p.costoPromedio || 0), 0);
const activos = productos.filter((p) => p.activo);
const bajoMinimo = activos.filter((p) => (p.stockActual || 0) <= (p.stockMinimo || 0));
const tarjetas = [
{ label: 'Valor del inventario', valor: DB.money(valorInventario), color: 'text-green-700' },
{ label: 'Productos activos', valor: activos.length, color: 'text-slate-800' },
{ label: 'Bajo stock mínimo', valor: bajoMinimo.length, color: bajoMinimo.length ? 'text-red-600' : 'text-slate-800' },
{ label: 'Entregas del mes', valor: '—', color: 'text-slate-400', nota: 'Disponible en la Fase 3' },
{ label: 'Actas sin firmar', valor: '—', color: 'text-slate-400', nota: 'Disponible en la Fase 3' },
];
return (
Panel
Resumen del inventario de insumos de Ecocosecha SAS
irA('entradas')} className="bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 px-4 py-2.5 rounded-lg text-sm font-bold">
+ Nueva entrada
irA('entregas')} className="bg-amber-500 hover:bg-amber-600 text-white px-4 py-2.5 rounded-lg text-sm font-bold">
+ Nueva entrega
{tarjetas.map((t) => (
{t.label}
{t.valor}
{t.nota &&
{t.nota}
}
))}
{bajoMinimo.length === 0 ? (
No hay productos por debajo del stock mínimo.
) : (
{bajoMinimo.map((p) => (
{p.descripcion}
{p.stockActual} / mín. {p.stockMinimo} {p.unidadEntrega}
))}
)}
Las gráficas de consumo mensual, top de productos entregados y consumo por área llegan en la Fase 4,
cuando ya existan entregas registradas.
);
}
/* ============================================================================
Productos — catálogo, formulario y fichas técnicas
============================================================================ */
const UNIDADES_SUGERIDAS = ['UNIDAD', 'PAQUETE', 'CAJA', 'PACA', 'GALÓN', 'RESMA', 'LIBRA', 'FRASCO', 'ROLLO', 'BOLSA'];
const TIPOS_FICHA = ['Ficha técnica', 'Hoja de seguridad SDS/FDS', 'Certificado', 'Otro'];
const MIME_PERMITIDOS = ['application/pdf', 'image/jpeg', 'image/png', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
function ProductoForm({ producto, categorias, onGuardar, onCancelar, modoCrear }) {
const esNuevo = modoCrear || !producto;
const [f, setF] = useState(
producto || {
codigoProveedor: '', descripcion: '', descripcionCorta: '', categoriaId: categorias[0]?.id || '',
marca: '', referencia: '', unidadCompra: 'UNIDAD', unidadEntrega: 'UNIDAD', factorConversion: 1,
ivaPct: 19, stockMinimo: 0, ubicacion: '', requiereFirma: true, esEPP: false, vidaUtilDias: '', activo: true,
}
);
const campo = (k) => (e) => setF((s) => ({ ...s, [k]: e.target.value }));
const campoBool = (k) => (e) => setF((s) => ({ ...s, [k]: e.target.checked }));
function submit(e) {
e.preventDefault();
if (!f.descripcion.trim()) return;
onGuardar({
...f,
factorConversion: Number(f.factorConversion) || 1,
ivaPct: Number(f.ivaPct) || 0,
stockMinimo: Number(f.stockMinimo) || 0,
vidaUtilDias: f.esEPP && f.vidaUtilDias !== '' ? Number(f.vidaUtilDias) : null,
});
}
const inputCls = 'w-full p-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-600 focus:border-green-600';
const labelCls = 'block text-xs font-semibold text-slate-600 mb-1';
return (
);
}
function FichasTecnicas({ producto, toast }) {
const [fichas, setFichas] = useState([]);
const [cargando, setCargando] = useState(true);
const [subiendo, setSubiendo] = useState(false);
const [tipo, setTipo] = useState('Ficha técnica');
const [vencimiento, setVencimiento] = useState('');
const [config, setConfig] = useState(null);
const [confirmarBorrar, setConfirmarBorrar] = useState(null);
const inputRef = useRef(null);
async function cargar() {
setCargando(true);
const [fs, cfg] = await Promise.all([DB.listarFichas(producto.id), DB.getConfiguracion()]);
setFichas(fs); setConfig(cfg); setCargando(false);
}
useEffect(() => { cargar(); /* eslint-disable-next-line */ }, [producto.id]);
async function subirArchivo(file) {
if (!file) return;
const maxMB = config?.tamanoMaxArchivoMB || 10;
if (!MIME_PERMITIDOS.includes(file.type)) {
toast('Formato no permitido. Usa PDF, JPG, PNG o DOCX.', 'error');
return;
}
if (file.size > maxMB * 1024 * 1024) {
toast(`El archivo pesa más de ${maxMB} MB.`, 'error');
return;
}
setSubiendo(true);
try {
const mismasFichas = fichas.filter((f) => f.tipo === tipo);
const version = mismasFichas.length ? Math.max(...mismasFichas.map((f) => f.version || 1)) + 1 : 1;
await DB.guardarFicha(producto.id, file, { tipo, version, fechaVencimiento: vencimiento || null });
toast('Documento cargado.');
setVencimiento('');
await cargar();
} catch (e) {
toast('No se pudo cargar el archivo: ' + e.message, 'error');
}
setSubiendo(false);
}
function verArchivo(ficha) {
const url = URL.createObjectURL(ficha.archivo);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 30000);
}
function descargarArchivo(ficha) {
const url = URL.createObjectURL(ficha.archivo);
const a = document.createElement('a');
a.href = url; a.download = ficha.nombreArchivo; a.click();
setTimeout(() => URL.revokeObjectURL(url), 4000);
}
async function borrarFicha(id) {
await DB.eliminarFicha(id);
setConfirmarBorrar(null);
toast('Documento eliminado.');
cargar();
}
return (
{cargando ? (
Cargando documentos…
) : fichas.length === 0 ? (
) : (
{fichas.map((f) => (
{f.nombreArchivo}
{f.tipo} · v{f.version} · {DB.fechaCO(f.fechaCarga)} · {(f.tamano / 1024).toFixed(0)} KB
{f.fechaVencimiento && (
{' '}· vence {DB.fechaCO(f.fechaVencimiento)}
)}
verArchivo(f)} className="text-green-700 hover:underline text-xs font-semibold">Ver
descargarArchivo(f)} className="text-slate-500 hover:text-slate-700" title="Descargar">
setConfirmarBorrar(f.id)} className="text-red-400 hover:text-red-600" title="Eliminar">
))}
)}
setConfirmarBorrar(null)}
onConfirmar={() => borrarFicha(confirmarBorrar)}
/>
);
}
const ETIQUETA_TIPO_MOV = {
ENTRADA: ['Entrada', '#16a34a'],
SALIDA: ['Salida', '#dc2626'],
AJUSTE_POSITIVO: ['Ajuste (+)', '#2563eb'],
AJUSTE_NEGATIVO: ['Ajuste (−)', '#b45309'],
ANULACION: ['Anulación', '#64748b'],
};
function Kardex({ producto }) {
const [movimientos, setMovimientos] = useState([]);
const [desde, setDesde] = useState('');
const [hasta, setHasta] = useState('');
useEffect(() => {
DB.queryIndex('movimientos', 'productoId', producto.id).then((ms) =>
setMovimientos(ms.sort((a, b) => new Date(a.fecha) - new Date(b.fecha)))
);
}, [producto.id]);
const filtrados = movimientos.filter((m) => {
const dia = (m.fecha || '').slice(0, 10);
if (desde && dia < desde) return false;
if (hasta && dia > hasta) return false;
return true;
});
function exportar() {
const filas = [
['Fecha', 'Tipo', 'Documento', 'Cantidad', 'Costo unitario', 'Saldo cantidad', 'Costo promedio', 'Saldo valor', 'Observación'],
...filtrados.map((m) => [
DB.fechaCO(m.fecha), ETIQUETA_TIPO_MOV[m.tipo]?.[0] || m.tipo, `${m.documentoTipo || ''} ${m.consecutivo || ''}`.trim(),
m.cantidad, m.costoUnitario, m.saldoCantidad, m.saldoCostoPromedio, m.saldoValor, m.observacion || '',
]),
];
const ws = window.XLSX.utils.aoa_to_sheet(filas);
const wb = window.XLSX.utils.book_new();
window.XLSX.utils.book_append_sheet(wb, ws, 'Kardex');
window.XLSX.writeFile(wb, `Kardex_${producto.codigoInterno}_${DB.hoyBogota()}.xlsx`);
}
return (
{filtrados.length === 0 ? (
) : (
Fecha Tipo Documento
Cantidad Costo unit.
Saldo cant. Costo prom. Saldo valor
{filtrados.map((m) => {
const [etiqueta, color] = ETIQUETA_TIPO_MOV[m.tipo] || [m.tipo, '#64748b'];
return (
{DB.fechaCO(m.fecha)}
{etiqueta}
{m.documentoTipo} {m.consecutivo}
= 0 ? 'text-green-700' : 'text-red-600')}>{m.cantidad >= 0 ? '+' : ''}{m.cantidad}
{DB.money(m.costoUnitario)}
{m.saldoCantidad}
{DB.money(m.saldoCostoPromedio)}
{DB.money(m.saldoValor)}
);
})}
)}
);
}
function ProductoDetalle({ producto, categoria, onVolver, onActualizado, toast }) {
const [tab, setTab] = useState('info');
const [editando, setEditando] = useState(false);
const [conteoFichas, setConteoFichas] = useState(null);
useEffect(() => { DB.listarFichas(producto.id).then((f) => setConteoFichas(f.length)); }, [producto.id, tab]);
async function guardar(datos) {
const actualizado = await DB.actualizarProducto(producto.id, datos);
toast('Producto actualizado.');
setEditando(false);
onActualizado(actualizado);
}
const TABS = [
{ id: 'info', label: 'Información' },
{ id: 'fichas', label: `Fichas técnicas${conteoFichas ? ` (${conteoFichas})` : ''}` },
{ id: 'kardex', label: 'Kardex' },
{ id: 'entregas', label: 'Entregas' },
];
return (
Volver al catálogo
{producto.codigoInterno}
{categoria && {categoria.nombre} }
{!producto.activo && Inactivo }
{producto.esEPP && EPP }
{producto.descripcion}
{!editando && (
setEditando(true)} className="bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2">
Editar
)}
Stock actual
{producto.stockActual} {producto.unidadEntrega}
Costo promedio
{DB.money(producto.costoPromedio)}
Valor en inventario
{DB.money(producto.stockActual * producto.costoPromedio)}
Stock mínimo
{producto.stockMinimo} {producto.unidadEntrega}
{TABS.map((t) => (
setTab(t.id)}
className={clsx(
'px-4 py-2.5 text-sm font-semibold whitespace-nowrap border-b-2 -mb-px',
tab === t.id ? 'border-green-700 text-green-800' : 'border-transparent text-slate-500 hover:text-slate-700'
)}
>
{t.label}
))}
{tab === 'info' && (editando ? (
setEditando(false)} />
) : (
{[
['Código proveedor', producto.codigoProveedor || '—'],
['Marca', producto.marca || '—'],
['Referencia', producto.referencia || '—'],
['Unidad de compra', producto.unidadCompra],
['Unidad de entrega', producto.unidadEntrega],
['Factor de conversión', `1 ${producto.unidadCompra} = ${producto.factorConversion} ${producto.unidadEntrega}`],
['IVA', `${producto.ivaPct}%`],
['Ubicación', producto.ubicacion || '—'],
['Requiere firma', producto.requiereFirma ? 'Sí' : 'No'],
['Vida útil EPP', producto.esEPP ? (producto.vidaUtilDias ? `${producto.vidaUtilDias} días` : 'No definida') : '—'],
].map(([k, v]) => (
{k}
{v}
))}
))}
{tab === 'fichas' && }
{tab === 'kardex' && }
{tab === 'entregas' && (
)}
);
}
function Productos({ productos, categorias, recargar, toast }) {
const [busqueda, setBusqueda] = useState('');
const [filtroCategoria, setFiltroCategoria] = useState('');
const [filtroStock, setFiltroStock] = useState('');
const [seleccionado, setSeleccionado] = useState(null);
const [modalNuevo, setModalNuevo] = useState(false);
const [importando, setImportando] = useState(false);
const [confirmarInactivar, setConfirmarInactivar] = useState(null);
const catPorId = useMemo(() => Object.fromEntries(categorias.map((c) => [c.id, c])), [categorias]);
const filtrados = useMemo(() => {
const q = busqueda.trim().toLowerCase();
return productos.filter((p) => {
if (filtroCategoria && p.categoriaId !== filtroCategoria) return false;
if (filtroStock === 'bajo' && !((p.stockActual || 0) <= (p.stockMinimo || 0))) return false;
if (filtroStock === 'ok' && (p.stockActual || 0) <= (p.stockMinimo || 0)) return false;
if (!q) return true;
return (p.descripcion + ' ' + p.codigoProveedor + ' ' + p.codigoInterno + ' ' + (p.marca || '')).toLowerCase().includes(q);
}).sort((a, b) => a.descripcion.localeCompare(b.descripcion, 'es'));
}, [productos, busqueda, filtroCategoria, filtroStock]);
async function crear(datos) {
const p = await DB.crearProducto(datos);
toast(`Producto creado: ${p.codigoInterno}`);
setModalNuevo(false);
await recargar();
}
async function inactivar(id) {
await DB.actualizarProducto(id, { activo: false });
setConfirmarInactivar(null);
toast('Producto inactivado.');
await recargar();
}
if (seleccionado) {
const actual = productos.find((p) => p.id === seleccionado.id) || seleccionado;
return (
setSeleccionado(null)}
onActualizado={async () => { await recargar(); }}
toast={toast}
/>
);
}
return (
Productos
{productos.length} en el catálogo · {productos.filter(p=>p.activo).length} activos
setImportando(true)} className="bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 px-4 py-2.5 rounded-lg text-sm font-bold flex items-center gap-2">
Importar Excel
setModalNuevo(true)} className="bg-green-700 hover:bg-green-800 text-white px-4 py-2.5 rounded-lg text-sm font-bold flex items-center gap-2">
Nuevo producto
{filtrados.length === 0 ? (
) : (
Código
Descripción
Categoría
Unidad
Stock
Mínimo
Costo prom.
Valor
Ficha
{filtrados.map((p) => {
const cat = catPorId[p.categoriaId];
return (
setSeleccionado(p)}>
{p.codigoInterno}{p.codigoProveedor}
{p.descripcion}
{cat && {cat.nombre} }
{p.unidadEntrega}
{p.stockActual}
{p.stockMinimo}
{DB.money(p.costoPromedio)}
{DB.money(p.stockActual * p.costoPromedio)}
e.stopPropagation()}>
{p.activo && (
setConfirmarInactivar(p.id)} className="text-xs text-slate-400 hover:text-red-600">Inactivar
)}
);
})}
)}
{modalNuevo && (
Nuevo producto
setModalNuevo(false)} className="text-slate-400 hover:text-white">
)}
setConfirmarInactivar(null)}
onConfirmar={() => inactivar(confirmarInactivar)}
/>
{importando && (
setImportando(false)}
/>
)}
);
}
function FichaIndicador({ productoId }) {
const [tiene, setTiene] = useState(null);
useEffect(() => { DB.listarFichas(productoId).then((f) => setTiene(f.length > 0)); }, [productoId]);
if (tiene === null) return null;
return tiene ? : — ;
}
/* ============================================================================
Buscador de producto (por código de proveedor, código interno o descripción)
============================================================================ */
function BuscadorProducto({ productos, onElegir, placeholder }) {
const [q, setQ] = useState('');
const [abierto, setAbierto] = useState(false);
const ref = useRef(null);
useEffect(() => {
function fuera(e) { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); }
document.addEventListener('mousedown', fuera);
return () => document.removeEventListener('mousedown', fuera);
}, []);
const norm = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const term = norm(q.trim());
const resultados = term
? productos.filter((p) => norm(`${p.codigoProveedor} ${p.codigoInterno} ${p.descripcion} ${p.marca || ''}`).includes(term)).slice(0, 30)
: [];
return (
{ setQ(e.target.value); setAbierto(true); }}
onFocus={() => setAbierto(true)}
placeholder={placeholder || 'Buscar producto por código o descripción…'}
className="w-full pl-9 pr-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
{abierto && resultados.length > 0 && (
{resultados.map((p) => (
e.preventDefault()}
onClick={() => { onElegir(p); setQ(''); setAbierto(false); }}
className="w-full text-left px-3 py-2 hover:bg-green-50 flex justify-between gap-2"
>
{p.descripcion}
{p.codigoProveedor}
))}
)}
);
}
/* ============================================================================
Entradas — recepción de mercancía del proveedor
============================================================================ */
function LineaEntradaEditor({ linea, producto, onCambiar, onQuitar }) {
const factor = producto?.factorConversion || 1;
const cantidadEntrega = DB.round0((linea.cantidadCompra || 0) * factor * 1000) / 1000;
const valorLinea = DB.round0((linea.cantidadCompra || 0) * (linea.valorUnitarioCompra || 0));
const inputCls = 'w-full p-1.5 border border-slate-300 rounded text-sm text-right';
return (
{producto?.descripcion}
{producto?.codigoProveedor} · {producto?.unidadCompra} → {producto?.unidadEntrega} (×{factor})
onCambiar({ ...linea, cantidadCompra: Number(e.target.value) })} />
{DB.round0(cantidadEntrega * 1000) / 1000} {producto?.unidadEntrega}
onCambiar({ ...linea, valorUnitarioCompra: Number(e.target.value) })} />
onCambiar({ ...linea, ivaPct: Number(e.target.value) })}>
0% 5% 19%
{DB.money(valorLinea)}
);
}
function EntradaForm({ productos, categorias, proveedores, valoresIniciales, recargarProductos, recargarProveedores, toast, onGuardada, onCancelar }) {
const [proveedorId, setProveedorId] = useState(valoresIniciales?.proveedorId || proveedores[0]?.id || '');
const [fechaRecepcion, setFechaRecepcion] = useState(valoresIniciales?.fechaRecepcion || DB.hoyBogota());
const [tipoSoporte, setTipoSoporte] = useState(valoresIniciales?.tipoSoporte || 'Remisión');
const [numeroRemision, setNumeroRemision] = useState(valoresIniciales?.numeroRemision || '');
const [numeroFactura, setNumeroFactura] = useState(valoresIniciales?.numeroFactura || '');
const [numeroPedido, setNumeroPedido] = useState(valoresIniciales?.numeroPedido || '');
const [totalFactura, setTotalFactura] = useState('');
const [observaciones, setObservaciones] = useState(valoresIniciales?.observaciones || '');
const [recibidoPor, setRecibidoPor] = useState('Director de Operaciones');
const [lineas, setLineas] = useState(
(valoresIniciales?.lineas || []).map((l) => ({ ...l, key: DB.uid() }))
);
const [adjuntos, setAdjuntos] = useState([]);
const [guardando, setGuardando] = useState(false);
const [nuevoProveedor, setNuevoProveedor] = useState(false);
const [productoRapido, setProductoRapido] = useState(null); // {descripcion, codigoProveedor} pendiente de crear
const productosPorId = useMemo(() => Object.fromEntries(productos.map((p) => [p.id, p])), [productos]);
function agregarProducto(p) {
if (lineas.some((l) => l.productoId === p.id)) { toast('Ese producto ya está en la lista.', 'warn'); return; }
setLineas((ls) => [...ls, { key: DB.uid(), productoId: p.id, cantidadCompra: 1, valorUnitarioCompra: 0, ivaPct: p.ivaPct || 0 }]);
}
function cambiarLinea(key, nueva) { setLineas((ls) => ls.map((l) => (l.key === key ? nueva : l))); }
function quitarLinea(key) { setLineas((ls) => ls.filter((l) => l.key !== key)); }
const totales = useMemo(() => DB.calcularTotales(lineas.map((l) => ({ ...l, ivaPct: l.ivaPct }))), [lineas]);
const diferenciaFactura = totalFactura !== '' ? Math.round(totales.total - Number(totalFactura)) : 0;
async function crearProveedorRapido(datos) {
const p = { id: DB.uid(), ...datos };
await DB.put('proveedores', p);
setProveedorId(p.id);
setNuevoProveedor(false);
await recargarProveedores();
toast('Proveedor creado.');
}
async function crearProductoRapido(datos) {
const p = await DB.crearProducto({ ...datos, codigoProveedor: productoRapido.codigoProveedor || datos.codigoProveedor, descripcion: datos.descripcion || productoRapido.descripcion });
await recargarProductos();
setProductoRapido(null);
agregarProducto(p);
toast(`Producto creado: ${p.codigoInterno}`);
}
function manejarAdjuntos(files) {
const arr = Array.from(files || []);
Promise.resolve().then(() => setAdjuntos((a) => [...a, ...arr.map((f) => ({ nombreArchivo: f.name, mime: f.type, archivo: f }))]));
}
async function guardar(e) {
e.preventDefault();
if (!proveedorId) { toast('Elige el proveedor.', 'error'); return; }
if (!lineas.length) { toast('Agrega al menos un producto.', 'error'); return; }
if (lineas.some((l) => !l.cantidadCompra || l.cantidadCompra <= 0)) { toast('Hay líneas con cantidad en cero.', 'error'); return; }
// Advertencia de posible duplicado (misma factura/remisión del mismo proveedor)
const entradasProv = await DB.queryIndex('entradas', 'proveedorId', proveedorId);
const posibleDup = entradasProv.find(
(e) => e.estado !== 'Anulada' && (
(numeroFactura && e.numeroFactura === numeroFactura) ||
(numeroRemision && e.numeroRemision === numeroRemision)
)
);
if (posibleDup) {
const continuar = window.confirm(
`Ya existe la entrada ${posibleDup.consecutivo} con ese mismo número de factura o remisión para este proveedor. ¿Registrar de todas formas?`
);
if (!continuar) return;
}
setGuardando(true);
try {
const { entrada } = await DB.registrarEntrada(
{
proveedorId, fechaRecepcion, tipoSoporte, numeroRemision, numeroFactura, numeroPedido,
observaciones, recibidoPor, adjuntos,
},
lineas.map((l) => ({ productoId: l.productoId, cantidadCompra: l.cantidadCompra, valorUnitarioCompra: l.valorUnitarioCompra, ivaPct: l.ivaPct, codigoProveedor: productosPorId[l.productoId]?.codigoProveedor }))
);
toast(`Entrada ${entrada.consecutivo} guardada por ${DB.money(entrada.total)}.`);
await recargarProductos();
onGuardada(entrada);
} catch (err) {
toast('No se pudo guardar: ' + err.message, 'error');
}
setGuardando(false);
}
const inputCls = 'w-full p-2.5 border border-slate-300 rounded-lg text-sm';
const labelCls = 'block text-xs font-semibold text-slate-600 mb-1';
return (
<>
{nuevoProveedor && (
Nuevo proveedor setNuevoProveedor(false)}>
setNuevoProveedor(false)} />
)}
{productoRapido && (
Nuevo producto setProductoRapido(null)}>
setProductoRapido(null)}
/>
)}
>
);
}
/* ============================================================================
Importación del Excel del proveedor (o la plantilla genérica)
============================================================================ */
const MESES_ES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
function normTxt(s) { return String(s ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').trim().toUpperCase(); }
function parsearFechaLarga(txt) {
const m = String(txt || '').match(/(\d{1,2})\s+de\s+([a-záéíóúñ]+)\s+del?\s+(\d{4})/i);
if (!m) return '';
const mesIdx = MESES_ES.findIndex((mm) => normTxt(mm) === normTxt(m[2]));
if (mesIdx === -1) return '';
return `${m[3]}-${String(mesIdx + 1).padStart(2, '0')}-${m[1].padStart(2, '0')}`;
}
function extraerTotalesArchivo(rows, startIdx) {
const out = {};
for (let i = startIdx; i < Math.min(startIdx + 6, rows.length); i++) {
const row = rows[i] || [];
const idx = row.findIndex((c) => /SUBTOTAL|FLETE|^IVA$|TOTAL/.test(normTxt(c)));
if (idx === -1) continue;
const etiqueta = normTxt(row[idx]);
const numeros = row.filter((c) => typeof c === 'number' || (c !== '' && c != null && !isNaN(parseFloat(c))));
const valor = Number(numeros[numeros.length - 1]) || 0;
if (etiqueta.includes('SUBTOTAL')) out.subtotal = valor;
else if (etiqueta.includes('FLETE')) out.flete = valor;
else if (etiqueta === 'IVA') out.iva = valor;
else if (etiqueta.includes('TOTAL')) out.total = valor;
}
return out;
}
function parsearExcelEntrada(rows) {
let headerIdx = -1, cols = {}, esPlantillaGenerica = false;
for (let i = 0; i < Math.min(rows.length, 25); i++) {
const row = Array.from(rows[i] || [], normTxt);
const iCodigo = row.indexOf('CODIGO');
const iDesc = row.findIndex((c) => c.startsWith('DESCRIPCION'));
if (iCodigo !== -1 && iDesc !== -1) {
headerIdx = i;
const iValorUnitario = row.findIndex((c) => (c.startsWith('VR') || c.startsWith('VALOR')) && c.includes('UNITARIO'));
cols = { codigo: iCodigo, descripcion: iDesc, cantidad: row.findIndex((c) => c.startsWith('CANT')), iva: row.indexOf('IVA'), valorUnitario: iValorUnitario };
// La plantilla genérica también trae columnas CÓDIGO y DESCRIPCIÓN, así que llega
// hasta aquí; se distingue del pedido real del proveedor porque su columna de
// valor dice "Valor unitario" (no "Vr. unitario") y no trae filas de encabezado
// adicionales (número de pedido, fecha) antes de la fila de títulos.
esPlantillaGenerica = headerIdx === 0 && iValorUnitario !== -1 && row[iValorUnitario].startsWith('VALOR');
break;
}
}
if (headerIdx === -1) {
// Plantilla genérica: encabezados en la fila 1.
const row0 = Array.from(rows[0] || [], normTxt);
const iCodigo = row0.findIndex((c) => c.includes('CODIGO'));
const iDesc = row0.findIndex((c) => c.includes('DESCRIPCION'));
if (iCodigo === -1 || iDesc === -1) throw new Error('No se encontró una fila de encabezados con "CODIGO" y "DESCRIPCIÓN".');
const iCant = row0.findIndex((c) => c.includes('CANTIDAD'));
const iValor = row0.findIndex((c) => c.includes('VALOR') && c.includes('UNITARIO'));
const iIva = row0.findIndex((c) => c.includes('IVA'));
const lineas = [];
for (let i = 1; i < rows.length; i++) {
const row = rows[i] || [];
const descripcion = row[iDesc];
if (!descripcion || String(descripcion).trim() === '') continue;
lineas.push({
codigoProveedor: String(row[iCodigo] ?? '').trim(),
descripcion: String(descripcion).trim(),
cantidadCompra: Number(row[iCant]) || 0,
ivaPct: Number(String(row[iIva] ?? '').replace('%', '')) || 0,
valorUnitarioCompra: Number(row[iValor]) || 0,
});
}
return { lineas, numeroPedido: '', fechaISO: '', totalesArchivo: null, formato: 'generico' };
}
let numeroPedido = '', fechaTexto = '';
for (let i = 0; i < headerIdx; i++) {
for (const celda of rows[i] || []) {
const txt = String(celda ?? '');
const mPedido = txt.match(/no\.?\s*(\d{3,})/i);
if (mPedido && !numeroPedido) numeroPedido = mPedido[1];
if (!fechaTexto && /\d{1,2}\s+de\s+[a-záéíóúñ]+\s+del?\s+\d{4}/i.test(txt)) fechaTexto = txt;
}
}
const lineas = [];
let totalesArchivo = null;
for (let i = headerIdx + 1; i < rows.length; i++) {
const row = rows[i] || [];
if (row.some((c) => normTxt(c).startsWith('SUBTOTAL'))) { totalesArchivo = extraerTotalesArchivo(rows, i); break; }
const codigo = row[cols.codigo];
if (codigo === undefined || codigo === null || String(codigo).trim() === '') continue;
const descripcion = String(row[cols.descripcion] ?? '').trim();
const cantidad = Number(row[cols.cantidad]) || 0;
if (!descripcion || !(cantidad > 0)) continue;
const ivaPct = Number(String(row[cols.iva] ?? '').replace('%', '').replace(',', '.')) || 0;
lineas.push({ codigoProveedor: String(codigo).trim(), descripcion, cantidadCompra: cantidad, ivaPct, valorUnitarioCompra: Number(row[cols.valorUnitario]) || 0 });
}
return { lineas, numeroPedido, fechaISO: parsearFechaLarga(fechaTexto), totalesArchivo, formato: esPlantillaGenerica ? 'generico' : 'proveedor' };
}
function descargarPlantillaGenerica() {
const wb = window.XLSX.utils.book_new();
const ws = window.XLSX.utils.aoa_to_sheet([
['Código', 'Descripción', 'Cantidad', 'Unidad', 'Valor unitario', 'IVA'],
['1544521', 'Bolsa de basura negra Jumbo 90x110 x6 und', 1, 'PAQUETE', 3313, '19%'],
]);
ws['!cols'] = [{ wch: 14 }, { wch: 36 }, { wch: 10 }, { wch: 12 }, { wch: 14 }, { wch: 8 }];
window.XLSX.utils.book_append_sheet(wb, ws, 'Entrada');
window.XLSX.writeFile(wb, 'Plantilla_Entrada_Inventario_Ecocosecha.xlsx');
}
/* ============================================================================
Importar productos por Excel (alta masiva, p. ej. dotación por tallas)
============================================================================ */
const ENCABEZADOS_PRODUCTOS = [
'Código proveedor', 'Descripción completa', 'Descripción corta', 'Categoría', 'Marca', 'Referencia', 'Talla',
'Unidad de compra', 'Unidad de entrega', 'Factor de conversión', 'IVA %', 'Stock mínimo', 'Es EPP (SI/NO)', 'Vida útil (días)', 'Ubicación',
];
function parsearExcelProductos(rows) {
const row0 = Array.from(rows[0] || [], normTxt);
const idx = (textos) => row0.findIndex((c) => textos.some((t) => c.includes(t)));
const iCodigo = idx(['CODIGO']);
const iDesc = idx(['DESCRIPCION COMPLETA', 'DESCRIPCION']);
const iDescCorta = idx(['DESCRIPCION CORTA']);
const iCategoria = idx(['CATEGORIA']);
const iMarca = idx(['MARCA']);
const iReferencia = idx(['REFERENCIA']);
const iTalla = idx(['TALLA']);
const iUnidadCompra = idx(['UNIDAD DE COMPRA', 'UNIDAD COMPRA']);
const iUnidadEntrega = idx(['UNIDAD DE ENTREGA', 'UNIDAD ENTREGA']);
const iFactor = idx(['FACTOR']);
const iIva = idx(['IVA']);
const iStockMin = idx(['STOCK MINIMO', 'MINIMO']);
const iEsEPP = idx(['EPP']);
const iVidaUtil = idx(['VIDA UTIL']);
const iUbicacion = idx(['UBICACION']);
if (iDesc === -1) throw new Error('No se encontró la columna "Descripción completa" en la fila de encabezados.');
const filas = [];
for (let i = 1; i < rows.length; i++) {
const row = rows[i] || [];
const descripcion = row[iDesc];
if (!descripcion || String(descripcion).trim() === '') continue;
filas.push({
codigoProveedor: iCodigo !== -1 ? String(row[iCodigo] ?? '').trim() : '',
descripcion: String(descripcion).trim(),
descripcionCorta: iDescCorta !== -1 ? String(row[iDescCorta] ?? '').trim() : '',
categoriaNombre: iCategoria !== -1 ? String(row[iCategoria] ?? '').trim() : '',
marca: iMarca !== -1 ? String(row[iMarca] ?? '').trim() : '',
referencia: iReferencia !== -1 ? String(row[iReferencia] ?? '').trim() : '',
talla: iTalla !== -1 ? String(row[iTalla] ?? '').trim() : '',
unidadCompra: (iUnidadCompra !== -1 ? String(row[iUnidadCompra] ?? '').trim().toUpperCase() : '') || 'UNIDAD',
unidadEntrega: (iUnidadEntrega !== -1 ? String(row[iUnidadEntrega] ?? '').trim().toUpperCase() : '') || 'UNIDAD',
factorConversion: iFactor !== -1 ? Number(row[iFactor]) || 1 : 1,
ivaPct: iIva !== -1 ? Number(String(row[iIva] ?? '').replace('%', '')) || 0 : 19,
stockMinimo: iStockMin !== -1 ? Number(row[iStockMin]) || 0 : 0,
esEPP: iEsEPP !== -1 ? /^S/i.test(String(row[iEsEPP] ?? '')) : false,
vidaUtilDias: iVidaUtil !== -1 && row[iVidaUtil] !== undefined && row[iVidaUtil] !== '' ? Number(row[iVidaUtil]) : null,
ubicacion: iUbicacion !== -1 ? String(row[iUbicacion] ?? '').trim() : '',
});
}
return filas;
}
function descargarPlantillaProductos() {
const wb = window.XLSX.utils.book_new();
const ws = window.XLSX.utils.aoa_to_sheet([
ENCABEZADOS_PRODUCTOS,
['CAM-001', 'Camisa dotación manga larga', 'Camisa dotación', 'Dotación y EPP', 'Ecocosecha', 'CAM-2026', 'S', 'UNIDAD', 'UNIDAD', 1, 19, 2, 'SI', 180, 'Bodega dotación'],
['CAM-001', 'Camisa dotación manga larga', 'Camisa dotación', 'Dotación y EPP', 'Ecocosecha', 'CAM-2026', 'M', 'UNIDAD', 'UNIDAD', 1, 19, 2, 'SI', 180, 'Bodega dotación'],
['CAM-001', 'Camisa dotación manga larga', 'Camisa dotación', 'Dotación y EPP', 'Ecocosecha', 'CAM-2026', 'L', 'UNIDAD', 'UNIDAD', 1, 19, 2, 'SI', 180, 'Bodega dotación'],
]);
ws['!cols'] = [{ wch: 14 }, { wch: 32 }, { wch: 20 }, { wch: 18 }, { wch: 14 }, { wch: 12 }, { wch: 8 }, { wch: 14 }, { wch: 14 }, { wch: 10 }, { wch: 7 }, { wch: 11 }, { wch: 12 }, { wch: 12 }, { wch: 16 }];
window.XLSX.utils.book_append_sheet(wb, ws, 'Productos');
window.XLSX.writeFile(wb, 'Plantilla_Productos_Inventario_Ecocosecha.xlsx');
}
function ImportarProductos({ productos, categorias, recargar, toast, onCancelar }) {
const [cargando, setCargando] = useState(false);
const [filas, setFilas] = useState(null); // null = aún no se eligió archivo
const [guardando, setGuardando] = useState(false);
const fileRef = useRef(null);
function resolverCategoria(nombre) {
if (!nombre) return null;
const norm = normTxt(nombre);
return categorias.find((c) => normTxt(c.nombre) === norm) || null;
}
function buscarExistente(fila) {
if (!fila.codigoProveedor) return null;
return productos.find((p) => p.codigoProveedor === fila.codigoProveedor && (p.talla || '') === (fila.talla || '')) || null;
}
async function elegirArchivo(file) {
if (!file) return;
setCargando(true);
try {
const buf = await file.arrayBuffer();
const wb = window.XLSX.read(buf, { type: 'array' });
const rows = window.XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, raw: true });
const parseadas = parsearExcelProductos(rows);
if (!parseadas.length) throw new Error('No se encontró ninguna fila con descripción.');
const resueltas = parseadas.map((f) => {
const categoria = resolverCategoria(f.categoriaNombre);
const existente = buscarExistente(f);
return { ...f, key: DB.uid(), categoriaId: categoria?.id || categorias[0]?.id || '', existenteId: existente?.id || null };
});
setFilas(resueltas);
toast(`${resueltas.length} fila(s) leídas del archivo.`);
} catch (e) {
toast('No se pudo leer el archivo: ' + e.message, 'error');
}
setCargando(false);
}
function cambiarCategoria(key, categoriaId) { setFilas((fs) => fs.map((f) => (f.key === key ? { ...f, categoriaId } : f))); }
const creaciones = filas ? filas.filter((f) => !f.existenteId).length : 0;
const actualizaciones = filas ? filas.filter((f) => f.existenteId).length : 0;
async function confirmar() {
setGuardando(true);
try {
for (const f of filas) {
const descripcionFinal = f.talla ? `${f.descripcion} - Talla ${f.talla}` : f.descripcion;
const descripcionCortaFinal = f.talla && f.descripcionCorta ? `${f.descripcionCorta} - ${f.talla}` : f.descripcionCorta;
const datos = {
codigoProveedor: f.codigoProveedor, descripcion: descripcionFinal, descripcionCorta: descripcionCortaFinal,
categoriaId: f.categoriaId, marca: f.marca, referencia: f.referencia, talla: f.talla,
unidadCompra: f.unidadCompra, unidadEntrega: f.unidadEntrega, factorConversion: f.factorConversion,
ivaPct: f.ivaPct, stockMinimo: f.stockMinimo, esEPP: f.esEPP, vidaUtilDias: f.esEPP ? f.vidaUtilDias : null,
ubicacion: f.ubicacion,
};
if (f.existenteId) await DB.actualizarProducto(f.existenteId, datos);
else await DB.crearProducto(datos);
}
toast(`Importación lista: ${creaciones} producto(s) creado(s), ${actualizaciones} actualizado(s).`);
await recargar();
onCancelar();
} catch (e) {
toast('No se pudo completar la importación: ' + e.message, 'error');
}
setGuardando(false);
}
return (
Importar productos desde Excel
{!filas ? (
Sube un Excel con una fila por producto (una fila por talla si aplica) usando la plantilla, o descárgala primero.
fileRef.current?.click()} className="bg-green-700 hover:bg-green-800 disabled:opacity-60 text-white px-4 py-2.5 rounded-lg text-sm font-bold">
{cargando ? 'Leyendo…' : 'Elegir archivo .xlsx'}
Descargar plantilla
elegirArchivo(e.target.files?.[0])} />
) : (
<>
{filas.length} fila(s) leídas
{creaciones} se crearán
{actualizaciones > 0 && {actualizaciones} actualizarán uno existente }
Código Descripción
Talla Categoría Acción
{filas.map((f) => (
{f.codigoProveedor || '—'}
{f.descripcion}{f.talla && · talla {f.talla} }
{f.talla || '—'}
cambiarCategoria(f.key, e.target.value)} className="border border-slate-300 rounded px-2 py-1 text-xs bg-white">
{categorias.map((c) => {c.nombre} )}
{f.existenteId ? (
Actualizará existente
) : (
Crear nuevo
)}
))}
setFilas(null)} className="px-4 py-2 text-sm rounded-lg border border-slate-300 text-slate-600 hover:bg-slate-50">Elegir otro archivo
{guardando ? 'Importando…' : `Importar ${filas.length} producto(s)`}
>
)}
);
}
function ImportarExcelEntrada({ productos, categorias, recargarProductos, toast, onResuelto, onCancelar }) {
const [cargando, setCargando] = useState(false);
const [resultado, setResultado] = useState(null); // { lineas, numeroPedido, fechaISO, totalesArchivo }
const [filas, setFilas] = useState([]); // lineas + producto resuelto
const [productoRapido, setProductoRapido] = useState(null);
const fileRef = useRef(null);
function emparejar(lineasExcel) {
return lineasExcel.map((l) => {
const producto = productos.find((p) => p.codigoProveedor && p.codigoProveedor === l.codigoProveedor);
return { ...l, key: DB.uid(), producto: producto || null };
});
}
async function elegirArchivo(file) {
if (!file) return;
setCargando(true);
try {
const buf = await file.arrayBuffer();
const wb = window.XLSX.read(buf, { type: 'array' });
const nombreHoja = wb.SheetNames.includes('Pedido') ? 'Pedido' : wb.SheetNames[0];
const rows = window.XLSX.utils.sheet_to_json(wb.Sheets[nombreHoja], { header: 1, raw: true });
const r = parsearExcelEntrada(rows);
setResultado(r);
setFilas(emparejar(r.lineas));
toast(`${r.lineas.length} línea(s) leídas del archivo.`);
} catch (e) {
toast('No se pudo leer el archivo: ' + e.message, 'error');
}
setCargando(false);
}
function asociar(key, producto) { setFilas((fs) => fs.map((f) => (f.key === key ? { ...f, producto } : f))); }
async function crearDesdeExcel(datos) {
const p = await DB.crearProducto({ ...datos, codigoProveedor: productoRapido.codigoProveedor, descripcion: datos.descripcion || productoRapido.descripcion });
await recargarProductos();
asociar(productoRapido.key, p);
setProductoRapido(null);
toast(`Producto creado: ${p.codigoInterno}`);
}
const pendientes = filas.filter((f) => !f.producto).length;
const totales = useMemo(() => DB.calcularTotales(filas.map((f) => ({ cantidadCompra: f.cantidadCompra, valorUnitarioCompra: f.valorUnitarioCompra, ivaPct: f.ivaPct }))), [filas]);
const totalArchivo = resultado?.totalesArchivo?.total;
const difArchivo = totalArchivo != null ? Math.round(totales.total - totalArchivo) : 0;
function continuar() {
if (pendientes > 0) { toast('Resuelve todas las líneas sin producto antes de continuar.', 'error'); return; }
onResuelto({
cabecera: { numeroPedido: resultado.numeroPedido, fechaRecepcion: resultado.fechaISO || DB.hoyBogota(), tipoSoporte: 'Pedido' },
lineas: filas.map((f) => ({ productoId: f.producto.id, cantidadCompra: f.cantidadCompra, valorUnitarioCompra: f.valorUnitarioCompra, ivaPct: f.ivaPct })),
});
}
return (
Importar Excel del proveedor
Cancelar
{!resultado ? (
Sube el pedido en Excel tal como lo manda el proveedor, o la plantilla genérica.
fileRef.current?.click()} className="bg-green-700 hover:bg-green-800 disabled:opacity-60 text-white px-4 py-2.5 rounded-lg text-sm font-bold">
{cargando ? 'Leyendo…' : 'Elegir archivo .xlsx'}
Descargar plantilla genérica
elegirArchivo(e.target.files?.[0])} />
) : (
<>
Formato: {resultado.formato === 'proveedor' ? 'Pedido del proveedor' : 'Plantilla genérica'}
{resultado.numeroPedido && Pedido N.° {resultado.numeroPedido} }
{resultado.fechaISO && Fecha {DB.fechaCO(resultado.fechaISO)} }
{filas.length} línea(s)
Código Descripción del Excel
Cant. Vr. unit. IVA
Producto en el sistema
{filas.map((f) => (
{f.codigoProveedor}
{f.descripcion}
{f.cantidadCompra}
{DB.money(f.valorUnitarioCompra)}
{f.ivaPct}%
{f.producto ? (
{f.producto.descripcion}
) : (
asociar(f.key, p)} placeholder="Asociar a producto existente…" />
setProductoRapido(f)} className="text-xs text-green-700 underline shrink-0">Crear nuevo
)}
))}
Subtotal calculado {DB.money(totales.subtotal)}
IVA calculado {DB.money(totales.iva)}
Total calculado {DB.money(totales.total)}
{totalArchivo != null && (
Total del archivo {DB.money(totalArchivo)}{difArchivo !== 0 && ` (dif. ${DB.money(Math.abs(difArchivo))})`}
)}
setResultado(null)} className="px-4 py-2 text-sm rounded-lg border border-slate-300 text-slate-600 hover:bg-slate-50">Elegir otro archivo
0} className="px-5 py-2.5 text-sm rounded-lg bg-green-700 hover:bg-green-800 disabled:opacity-50 text-white font-bold">
{pendientes > 0 ? `Resuelve ${pendientes} línea(s) pendientes` : 'Continuar al formulario de entrada'}
>
)}
{productoRapido && (
Nuevo producto setProductoRapido(null)}>
setProductoRapido(null)}
/>
)}
);
}
/* ============================================================================
Detalle de entrada (ver, anular, imprimir comprobante)
============================================================================ */
function ComprobanteEntradaImprimible({ entrada, detalles, proveedor, config }) {
return (
// Ancho fijo (816px = una hoja carta a 96dpi) para que la "foto" que toma
// html2canvas al generar el PDF siempre salga al mismo tamaño relativo,
// sin importar qué tan ancha esté la ventana del navegador: si se deja en
// 100% del contenedor, en pantallas anchas el texto queda chiquito porque
// la imagen capturada es más ancha de lo normal y luego se encoge para
// caber en el ancho de la página.
{config.empresa.razonSocial}
NIT {config.empresa.nit}
{config.empresa.direccion}
COMPROBANTE DE ENTRADA
Consecutivo: {entrada.consecutivo}
Fecha: {DB.fechaCO(entrada.fechaRecepcion)}
Proveedor: {proveedor?.razonSocial || '—'} {proveedor?.nit ? `· NIT ${proveedor.nit}` : ''}
Soporte: {entrada.tipoSoporte} {entrada.numeroFactura || entrada.numeroRemision || entrada.numeroPedido}
Recibido por: {entrada.recibidoPor || '—'}
Estado: {entrada.estado}
Código
Descripción
Cant. compra
Cant. entrega
Vr. unitario
IVA
Total
{detalles.map((d) => (
{d.codigoProveedor}
{d.descripcion}
{d.cantidadCompra} {d.unidadCompra}
{d.cantidadEntrega}
{DB.money(d.valorUnitarioCompra)}
{d.ivaPct}%
{DB.money(d.valorTotal)}
))}
Subtotal {DB.money(entrada.subtotal)}
IVA {DB.money(entrada.iva)}
Total {DB.money(entrada.total)}
{entrada.observaciones &&
Observaciones: {entrada.observaciones}
}
{entrada.estado === 'Anulada' &&
ANULADA: {entrada.motivoAnulacion}
}
);
}
function EditarEntradaForm({ entrada, proveedores, onGuardar, onCancelar }) {
const [f, setF] = useState({
proveedorId: entrada.proveedorId || '', tipoSoporte: entrada.tipoSoporte || 'Remisión',
numeroRemision: entrada.numeroRemision || '', numeroFactura: entrada.numeroFactura || '',
fechaRecepcion: entrada.fechaRecepcion || '', recibidoPor: entrada.recibidoPor || '',
observaciones: entrada.observaciones || '',
});
const campo = (k) => (e) => setF((s) => ({ ...s, [k]: e.target.value }));
const inputCls = 'w-full p-2.5 border border-slate-300 rounded-lg text-sm';
const labelCls = 'block text-xs font-semibold text-slate-600 mb-1';
function submit(e) { e.preventDefault(); onGuardar(f); }
return (
Solo se pueden editar estos datos. Para corregir cantidades o costos de las líneas, anula esta entrada y regístrala de nuevo.
Proveedor
— sin proveedor —
{proveedores.map((p) => {p.razonSocial} )}
Recibido por
Observaciones
Cancelar
Guardar cambios
);
}
function EntradaDetalleView({ entrada, proveedores, config, recargarProductos, toast, onVolver, onAnulada, onActualizada }) {
const [detalles, setDetalles] = useState([]);
const [confirmarAnular, setConfirmarAnular] = useState(false);
const [editando, setEditando] = useState(false);
const [motivo, setMotivo] = useState('');
const proveedor = proveedores.find((p) => p.id === entrada.proveedorId);
useEffect(() => { DB.queryIndex('entradaDetalle', 'entradaId', entrada.id).then(setDetalles); }, [entrada.id]);
async function anular() {
if (!motivo.trim()) { toast('Escribe el motivo de anulación.', 'error'); return; }
try {
await DB.anularEntrada(entrada.id, motivo.trim());
toast(`Entrada ${entrada.consecutivo} anulada.`);
setConfirmarAnular(false);
await recargarProductos();
onAnulada();
} catch (e) {
toast('No se pudo anular: ' + e.message, 'error');
}
}
async function guardarEdicion(patch) {
try {
const nueva = await DB.actualizarEntrada(entrada.id, patch);
toast('Entrada actualizada.');
setEditando(false);
onActualizada(nueva);
} catch (e) {
toast('No se pudo guardar: ' + e.message, 'error');
}
}
return (
Volver a entradas
window.print()} className="bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2">
Imprimir comprobante
{entrada.estado === 'Activa' && (
<>
setEditando(true)} className="bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2">
Editar
setConfirmarAnular(true)} className="bg-white border border-red-300 hover:bg-red-50 text-red-600 px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2">
Anular
>
)}
{entrada.consecutivo}
{entrada.estado === 'Anulada' ? Anulada : Activa }
{editando && (
Editar entrada {entrada.consecutivo}
setEditando(false)}>
setEditando(false)} />
)}
Esto revierte en el kardex toda la mercancía que entró con {entrada.consecutivo}. No se puede deshacer.
setMotivo(e.target.value)} placeholder="Motivo de la anulación…" className="w-full p-2 border border-slate-300 rounded text-sm" />
}
peligro
textoConfirmar="Anular entrada"
onCancelar={() => setConfirmarAnular(false)}
onConfirmar={anular}
/>
);
}
/* ============================================================================
Entradas — lista + navegación entre formulario / importar / detalle
============================================================================ */
function Entradas({ productos, categorias, proveedores, recargarProductos, recargarProveedores, config, toast, abrirNuevo, onAbrirNuevoConsumido }) {
const [sub, setSub] = useState('lista'); // lista | nuevo | importar | detalle
const [entradas, setEntradas] = useState([]);
const [seleccionada, setSeleccionada] = useState(null);
const [borrador, setBorrador] = useState(null);
const [filtroProveedor, setFiltroProveedor] = useState('');
const [filtroEstado, setFiltroEstado] = useState('');
async function cargar() { setEntradas((await DB.getAll('entradas')).sort((a, b) => b.fechaCreacion.localeCompare(a.fechaCreacion))); }
useEffect(() => { cargar(); }, []);
useEffect(() => { if (abrirNuevo) { setSub('nuevo'); setBorrador(null); onAbrirNuevoConsumido(); } }, [abrirNuevo]);
const filtradas = entradas.filter((e) => (!filtroProveedor || e.proveedorId === filtroProveedor) && (!filtroEstado || e.estado === filtroEstado));
const proveedoresPorId = Object.fromEntries(proveedores.map((p) => [p.id, p]));
if (sub === 'nuevo') {
return (
{borrador ? 'Confirmar entrada importada' : 'Nueva entrada'}
{ await cargar(); setSeleccionada(entrada); setBorrador(null); setSub('detalle'); }}
onCancelar={() => { setBorrador(null); setSub('lista'); }}
/>
);
}
if (sub === 'importar') {
return (
{ setBorrador(draft); setSub('nuevo'); }}
onCancelar={() => setSub('lista')}
/>
);
}
if (sub === 'detalle' && seleccionada) {
return (
setSub('lista')}
onAnulada={async () => { await cargar(); setSub('lista'); }}
onActualizada={async (nueva) => { setSeleccionada(nueva); await cargar(); }}
/>
);
}
return (
Entradas
{entradas.length} registradas
setSub('importar')} className="bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 px-4 py-2.5 rounded-lg text-sm font-bold flex items-center gap-2">
Importar Excel
{ setBorrador(null); setSub('nuevo'); }} className="bg-green-700 hover:bg-green-800 text-white px-4 py-2.5 rounded-lg text-sm font-bold flex items-center gap-2">
Nueva entrada
setFiltroProveedor(e.target.value)} className="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
Todos los proveedores
{proveedores.map((p) => {p.razonSocial} )}
setFiltroEstado(e.target.value)} className="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
Cualquier estado
Activa
Anulada
{filtradas.length === 0 ? (
) : (
Consecutivo Fecha
Proveedor Soporte
Total Estado
{filtradas.map((e) => (
{ setSeleccionada(e); setSub('detalle'); }}>
{e.consecutivo}
{DB.fechaCO(e.fechaRecepcion)}
{proveedoresPorId[e.proveedorId]?.razonSocial || '—'}
{e.tipoSoporte} {e.numeroFactura || e.numeroRemision || e.numeroPedido}
{DB.money(e.total)}
{e.estado === 'Anulada' ? Anulada : Activa }
))}
)}
);
}
/* ============================================================================
Proveedores — CRUD simple + historial de compras
============================================================================ */
function ProveedorForm({ proveedor, onGuardar, onCancelar }) {
const [f, setF] = useState(proveedor || { razonSocial: '', nit: '', direccion: '', ciudad: '', telefono: '', email: '', contacto: '', celularContacto: '', activo: true });
const campo = (k) => (e) => setF((s) => ({ ...s, [k]: e.target.value }));
const inputCls = 'w-full p-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-600 focus:border-green-600';
const labelCls = 'block text-xs font-semibold text-slate-600 mb-1';
function submit(e) {
e.preventDefault();
if (!f.razonSocial.trim()) return;
onGuardar(f);
}
return (
Cancelar
Guardar
);
}
/* ============================================================================
Buscador de colaborador (por nombre o cédula)
============================================================================ */
function BuscadorColaborador({ colaboradores, onElegir, placeholder }) {
const [q, setQ] = useState('');
const [abierto, setAbierto] = useState(false);
const ref = useRef(null);
useEffect(() => {
function fuera(e) { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); }
document.addEventListener('mousedown', fuera);
return () => document.removeEventListener('mousedown', fuera);
}, []);
const norm = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const term = norm(q.trim());
const resultados = term ? colaboradores.filter((c) => norm(`${c.nombreCompleto} ${c.cedula}`).includes(term)).slice(0, 30) : [];
return (
{ setQ(e.target.value); setAbierto(true); }} onFocus={() => setAbierto(true)} placeholder={placeholder || 'Buscar colaborador por nombre o cédula…'} className="w-full pl-9 pr-3 py-2 border border-slate-300 rounded-lg text-sm" />
{abierto && resultados.length > 0 && (
{resultados.map((c) => (
e.preventDefault()} onClick={() => { onElegir(c); setQ(''); setAbierto(false); }} className="w-full text-left px-3 py-2 hover:bg-green-50 flex justify-between gap-2">
{c.nombreCompleto} {c.cedula}
))}
)}
);
}
/* ============================================================================
Firma digital en pantalla (canvas táctil)
============================================================================ */
function FirmaDigitalCanvas({ onGuardar, onCancelar }) {
const canvasRef = useRef(null);
const dibujando = useRef(false);
const vacio = useRef(true);
useEffect(() => {
const ctx = canvasRef.current.getContext('2d');
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.strokeStyle = '#1e293b';
}, []);
function posicion(e) {
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const punto = e.touches ? e.touches[0] : e;
return { x: (punto.clientX - rect.left) * (canvas.width / rect.width), y: (punto.clientY - rect.top) * (canvas.height / rect.height) };
}
function iniciar(e) {
e.preventDefault();
dibujando.current = true;
const { x, y } = posicion(e);
const ctx = canvasRef.current.getContext('2d');
ctx.beginPath();
ctx.moveTo(x, y);
}
function mover(e) {
if (!dibujando.current) return;
e.preventDefault();
const { x, y } = posicion(e);
const ctx = canvasRef.current.getContext('2d');
ctx.lineTo(x, y);
ctx.stroke();
vacio.current = false;
}
function terminar() { dibujando.current = false; }
function limpiar() {
const canvas = canvasRef.current;
canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
vacio.current = true;
}
function guardar() {
if (vacio.current) return;
onGuardar(canvasRef.current.toDataURL('image/png'));
}
return (
El colaborador firma aquí con el dedo, el mouse o el lápiz táctil (funciona bien en celular o tablet).
Limpiar
Cancelar
Guardar firma
);
}
/* ============================================================================
Entregas — salida de insumos a un colaborador + acta de entrega
============================================================================ */
function AvisoEPP({ producto, colaboradorId }) {
const [info, setInfo] = useState(null);
useEffect(() => {
let cancelado = false;
if (!producto?.esEPP || !colaboradorId || !producto.vidaUtilDias) { setInfo(null); return; }
DB.ultimaEntregaProductoColaborador(producto.id, colaboradorId).then((e) => {
if (cancelado) return;
if (!e) { setInfo(null); return; }
const dias = Math.floor((new Date() - new Date(e.fecha)) / 86400000);
if (dias < producto.vidaUtilDias) setInfo({ fecha: e.fecha, dias, faltan: producto.vidaUtilDias - dias });
else setInfo(null);
});
return () => { cancelado = true; };
}, [producto?.id, colaboradorId]);
if (!info) return null;
return (
Ya se le entregó el {DB.fechaCO(info.fecha)} (hace {info.dias} días); la vida útil son {producto.vidaUtilDias} días — faltan {info.faltan}.
);
}
function LineaEntregaEditor({ linea, producto, colaboradorId, onCambiar, onQuitar }) {
const excede = producto && linea.cantidad > (producto.stockActual || 0);
return (
{producto?.descripcion}
Disponible: {producto?.stockActual} {producto?.unidadEntrega}
onCambiar({ ...linea, cantidad: Number(e.target.value) })} />
{excede && Supera el disponible
}
{producto?.esEPP ? onCambiar({ ...linea, talla: e.target.value })} /> : — }
onCambiar({ ...linea, observacion: e.target.value })} />
);
}
const TIPOS_ENTREGA = ['Dotación', 'EPP', 'Insumos de aseo', 'Cafetería', 'Papelería', 'Mixta'];
const MOTIVOS_ENTREGA = ['Primera entrega', 'Reposición por desgaste', 'Reposición por pérdida', 'Consumo del área', 'Otro'];
function EntregaForm({ productos, colaboradores, config, recargarProductos, recargarColaboradores, toast, onGuardada, onCancelar }) {
const [colaboradorId, setColaboradorId] = useState('');
const [fecha, setFecha] = useState(DB.hoyBogota());
const [tipoEntrega, setTipoEntrega] = useState('Dotación');
const [motivo, setMotivo] = useState('Primera entrega');
const [entregadoPor, setEntregadoPor] = useState(config?.responsableEntregas?.nombre || 'Director de Operaciones');
const [observaciones, setObservaciones] = useState('');
const [lineas, setLineas] = useState([]);
const [guardando, setGuardando] = useState(false);
const [nuevoColaborador, setNuevoColaborador] = useState(false);
const colaborador = colaboradores.find((c) => c.id === colaboradorId);
const productosPorId = useMemo(() => Object.fromEntries(productos.map((p) => [p.id, p])), [productos]);
function agregarProducto(p) {
if (lineas.some((l) => l.productoId === p.id)) { toast('Ese producto ya está en la lista.', 'warn'); return; }
setLineas((ls) => [...ls, { key: DB.uid(), productoId: p.id, cantidad: 1, talla: '', observacion: '' }]);
}
function cambiarLinea(key, nueva) { setLineas((ls) => ls.map((l) => (l.key === key ? nueva : l))); }
function quitarLinea(key) { setLineas((ls) => ls.filter((l) => l.key !== key)); }
async function crearColaboradorRapido(datos) {
const c = { id: DB.uid(), ...datos };
await DB.put('colaboradores', c);
setColaboradorId(c.id);
setNuevoColaborador(false);
await recargarColaboradores();
toast('Colaborador creado.');
}
const hayExcedentes = lineas.some((l) => { const p = productosPorId[l.productoId]; return p && l.cantidad > (p.stockActual || 0); });
async function guardar(e) {
e.preventDefault();
if (!colaboradorId) { toast('Elige el colaborador.', 'error'); return; }
if (!lineas.length) { toast('Agrega al menos un producto.', 'error'); return; }
if (lineas.some((l) => !l.cantidad || l.cantidad <= 0)) { toast('Hay líneas con cantidad en cero.', 'error'); return; }
if (hayExcedentes) { toast('Hay líneas que superan el stock disponible.', 'error'); return; }
setGuardando(true);
try {
const { entrega } = await DB.registrarEntrega(
{ colaboradorId, fecha, tipoEntrega, motivo, entregadoPor, observaciones, area: colaborador?.area || '' },
lineas.map((l) => ({ productoId: l.productoId, cantidad: l.cantidad, talla: l.talla, observacion: l.observacion }))
);
toast(`Entrega ${entrega.consecutivo} guardada.`);
await recargarProductos();
onGuardada(entrega);
} catch (err) {
toast('No se pudo guardar: ' + err.message, 'error');
}
setGuardando(false);
}
const inputCls = 'w-full p-2.5 border border-slate-300 rounded-lg text-sm';
const labelCls = 'block text-xs font-semibold text-slate-600 mb-1';
return (
<>
Colaborador
c.activo)} onElegir={(c) => setColaboradorId(c.id)} />
setNuevoColaborador(true)} className="shrink-0 px-3 border border-slate-300 rounded-lg text-sm hover:bg-slate-50">+ Nuevo
{colaborador && (
Nombre
{colaborador.nombreCompleto}
Cédula
{colaborador.cedula || '—'}
Cargo
{colaborador.cargo || '—'}
Área
{colaborador.area || '—'}
)}
Datos de la entrega
Observaciones setObservaciones(e.target.value)} />
Productos a entregar
p.activo && p.stockActual > 0)} onElegir={agregarProducto} placeholder="Busca el producto a entregar (solo se ven los que tienen stock)…" />
{lineas.length > 0 && (
Producto Cantidad Talla Observación
{lineas.map((l) => (
cambiarLinea(l.key, n)} onQuitar={() => quitarLinea(l.key)} />
))}
)}
Cancelar
{guardando ? 'Guardando…' : 'Guardar y generar acta'}
{nuevoColaborador && (
Nuevo colaborador setNuevoColaborador(false)}>
setNuevoColaborador(false)} />
)}
>
);
}
/* ---------- Acta de entrega imprimible ---------- */
function ActaEntregaImprimible({ entrega, detalles, colaborador, config }) {
const FILAS_MINIMAS = 10;
const filasVacias = Math.max(0, FILAS_MINIMAS - detalles.length);
return (
// Ancho fijo (816px = una hoja carta a 96dpi), ver comentario en
// ComprobanteEntradaImprimible — mismo motivo.
{config.empresa.razonSocial}
NIT {config.empresa.nit}
ACTA DE ENTREGA DE DOTACIÓN / INSUMOS
Consecutivo: {entrega.consecutivo}
Fecha: {DB.fechaCO(entrega.fecha)} · Hora: {DB.horaCO(entrega.hora)}
{config.actaFormato.codigo} · Versión {config.actaFormato.version}
Colaborador: {colaborador?.nombreCompleto || '—'}
Cédula: {colaborador?.cedula || '—'}
Cargo: {colaborador?.cargo || '—'}
Área: {entrega.area || colaborador?.area || '—'}
Tipo de entrega: {entrega.tipoEntrega}
Motivo: {entrega.motivo}
N.°
Descripción
Unidad
Cant.
Talla
Observación
{detalles.map((d, i) => (
{i + 1}
{d.descripcion}
{d.unidadEntrega}
{d.cantidad}
{d.talla || '—'}
{d.observacion}
))}
{Array.from({ length: filasVacias }).map((_, i) => (
))}
{entrega.observaciones &&
Observaciones: {entrega.observaciones}
}
{config.actaDeclaracion}
Firma
Entregado por: {entrega.entregadoPor || config.responsableEntregas.nombre}
{config.responsableEntregas.cargo}
C.C. {config.responsableEntregas.cedula || '___________________'}
{entrega.firmaDigitalColaborador &&
}
Firma
Recibido por: {colaborador?.nombreCompleto}
C.C. {colaborador?.cedula || '___________________'}
Original: Archivo Ecocosecha — Copia: Colaborador
{entrega.estado === 'Anulada' && ANULADA: {entrega.motivoAnulacion} }
);
}
// El acta y el comprobante son documentos de una sola hoja por diseño
// (FILAS_MINIMAS en ActaEntregaImprimible). En vez de partir el contenido en
// varias páginas si por algún motivo queda más alto de lo normal (lo que
// antes producía una segunda hoja con el bloque de firmas cortado o
// repetido), se encoge la imagen completa lo necesario para que SIEMPRE
// quepa entera en una sola página carta.
async function descargarPdfUnaPagina(nodo, nombreArchivo) {
if (!nodo || !window.html2canvas || !window.jspdf) throw new Error('No se pudo generar el PDF (no cargaron las librerías).');
const canvas = await window.html2canvas(nodo, { scale: 2, backgroundColor: '#ffffff' });
const img = canvas.toDataURL('image/png');
const { jsPDF } = window.jspdf;
const pdf = new jsPDF({ unit: 'mm', format: 'letter', orientation: 'portrait' });
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const margin = 10;
const maxWidth = pageWidth - margin * 2;
const maxHeight = pageHeight - margin * 2;
const escala = Math.min(maxWidth / canvas.width, maxHeight / canvas.height);
const imgWidth = canvas.width * escala;
const imgHeight = canvas.height * escala;
const x = (pageWidth - imgWidth) / 2;
pdf.addImage(img, 'PNG', x, margin, imgWidth, imgHeight);
pdf.save(nombreArchivo);
}
async function descargarActaPDF(entrega) {
await descargarPdfUnaPagina(document.getElementById('imprimible'), `${entrega.consecutivo}_acta_entrega.pdf`);
}
function EditarEntregaForm({ entrega, onGuardar, onCancelar }) {
const [f, setF] = useState({
entregadoPor: entrega.entregadoPor || '', fecha: entrega.fecha || '', tipoEntrega: entrega.tipoEntrega || 'Dotación',
motivo: entrega.motivo || '', observaciones: entrega.observaciones || '',
});
const campo = (k) => (e) => setF((s) => ({ ...s, [k]: e.target.value }));
const inputCls = 'w-full p-2.5 border border-slate-300 rounded-lg text-sm';
const labelCls = 'block text-xs font-semibold text-slate-600 mb-1';
function submit(e) { e.preventDefault(); onGuardar(f); }
return (
Solo se pueden editar estos datos. Para corregir los productos o cantidades entregadas, anula esta entrega y regístrala de nuevo.
Motivo
{MOTIVOS_ENTREGA.map((m) => {m} )}
Entregado por
Observaciones
Cancelar
Guardar cambios
);
}
function EntregaDetalleView({ entrega, colaboradores, config, recargarProductos, toast, onVolver, onAnulada, onActualizada }) {
const [detalles, setDetalles] = useState([]);
const [confirmarAnular, setConfirmarAnular] = useState(false);
const [editando, setEditando] = useState(false);
const [motivo, setMotivo] = useState('');
const [firmando, setFirmando] = useState(false);
const [descargando, setDescargando] = useState(false);
const colaborador = colaboradores.find((c) => c.id === entrega.colaboradorId);
const escaneadoRef = useRef(null);
useEffect(() => { DB.queryIndex('entregaDetalle', 'entregaId', entrega.id).then(setDetalles); }, [entrega.id]);
async function anular() {
if (!motivo.trim()) { toast('Escribe el motivo de anulación.', 'error'); return; }
try {
await DB.anularEntrega(entrega.id, motivo.trim());
toast(`Entrega ${entrega.consecutivo} anulada.`);
setConfirmarAnular(false);
await recargarProductos();
onAnulada();
} catch (e) { toast('No se pudo anular: ' + e.message, 'error'); }
}
async function marcarFirmada() {
const actualizada = await DB.actualizarEntrega(entrega.id, { estado: 'Firmada' });
toast('Marcada como firmada.');
onActualizada(actualizada);
}
async function guardarFirmaDigital(dataUrl) {
const actualizada = await DB.actualizarEntrega(entrega.id, { firmaDigitalColaborador: dataUrl, estado: 'Firmada' });
setFirmando(false);
toast('Firma guardada.');
onActualizada(actualizada);
}
async function adjuntarEscaneada(file) {
if (!file) return;
const actualizada = await DB.actualizarEntrega(entrega.id, { actaFirmadaEscaneada: file, estado: 'Firmada' });
toast('Acta escaneada adjuntada.');
onActualizada(actualizada);
}
function verEscaneada() {
if (!entrega.actaFirmadaEscaneada) return;
const url = URL.createObjectURL(entrega.actaFirmadaEscaneada);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 30000);
}
async function descargarPDF() {
setDescargando(true);
try { await descargarActaPDF(entrega); } catch (e) { toast(e.message, 'error'); }
setDescargando(false);
}
async function guardarEdicion(patch) {
try {
const actualizada = await DB.actualizarEntrega(entrega.id, patch);
toast('Entrega actualizada.');
setEditando(false);
onActualizada(actualizada);
} catch (e) {
toast('No se pudo guardar: ' + e.message, 'error');
}
}
return (
Volver a entregas
window.print()} className="bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2">
Imprimir
{descargando ? 'Generando…' : 'Descargar PDF'}
{entrega.estado !== 'Anulada' && (
<>
setEditando(true)} className="bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2">
Editar
setFirmando(true)} className="bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 px-4 py-2 rounded-lg text-sm font-bold">Firma digital
{entrega.estado === 'Pendiente de firma' && (
Marcar como firmada
)}
escaneadoRef.current?.click()} className="bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2"> Adjuntar escaneada
{ adjuntarEscaneada(e.target.files?.[0]); e.target.value = ''; }} />
setConfirmarAnular(true)} className="bg-white border border-red-300 hover:bg-red-50 text-red-600 px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2"> Anular
>
)}
{entrega.consecutivo}
{entrega.estado}
{entrega.actaFirmadaEscaneada && Ver acta escaneada }
{firmando && (
Firma digital del colaborador
setFirmando(false)} />
)}
{editando && (
Editar entrega {entrega.consecutivo}
setEditando(false)}>
setEditando(false)} />
)}
Esto repone en el kardex todo lo entregado con {entrega.consecutivo}. No se puede deshacer.
setMotivo(e.target.value)} placeholder="Motivo de la anulación…" className="w-full p-2 border border-slate-300 rounded text-sm" />
}
peligro
textoConfirmar="Anular entrega"
onCancelar={() => setConfirmarAnular(false)}
onConfirmar={anular}
/>
);
}
function Entregas({ productos, colaboradores, config, recargarProductos, recargarColaboradores, toast, abrirNuevo, onAbrirNuevoConsumido }) {
const [sub, setSub] = useState('lista'); // lista | nuevo | detalle
const [entregas, setEntregas] = useState([]);
const [seleccionada, setSeleccionada] = useState(null);
const [filtroColaborador, setFiltroColaborador] = useState('');
const [filtroEstado, setFiltroEstado] = useState('');
async function cargar() { setEntregas((await DB.getAll('entregas')).sort((a, b) => b.fechaCreacion.localeCompare(a.fechaCreacion))); }
useEffect(() => { cargar(); }, []);
useEffect(() => { if (abrirNuevo) { setSub('nuevo'); onAbrirNuevoConsumido(); } }, [abrirNuevo]);
const colaboradoresPorId = Object.fromEntries(colaboradores.map((c) => [c.id, c]));
const filtradas = entregas.filter((e) => (!filtroColaborador || e.colaboradorId === filtroColaborador) && (!filtroEstado || e.estado === filtroEstado));
if (sub === 'nuevo') {
return (
Nueva entrega
{ await cargar(); setSeleccionada(entrega); setSub('detalle'); }}
onCancelar={() => setSub('lista')}
/>
);
}
if (sub === 'detalle' && seleccionada) {
return (
setSub('lista')}
onAnulada={async () => { await cargar(); setSub('lista'); }}
onActualizada={async (actualizada) => { setSeleccionada(actualizada); await cargar(); }}
/>
);
}
return (
Entregas
{entregas.length} registradas
setSub('nuevo')} className="bg-amber-500 hover:bg-amber-600 text-white px-4 py-2.5 rounded-lg text-sm font-bold flex items-center gap-2">
Nueva entrega
setFiltroColaborador(e.target.value)} className="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
Todos los colaboradores
{colaboradores.map((c) => {c.nombreCompleto} )}
setFiltroEstado(e.target.value)} className="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
Cualquier estado
Pendiente de firma
Firmada
Anulada
{filtradas.length === 0 ? (
) : (
Consecutivo Fecha
Colaborador Tipo
Valor Estado
{filtradas.map((e) => (
{ setSeleccionada(e); setSub('detalle'); }}>
{e.consecutivo}
{DB.fechaCO(e.fecha)}
{colaboradoresPorId[e.colaboradorId]?.nombreCompleto || '—'}
{e.tipoEntrega}
{DB.money(e.valorTotal)}
{e.estado}
))}
)}
);
}
function Proveedores({ proveedores, recargar, toast, onVerEntrada }) {
const [modal, setModal] = useState(null); // null | 'nuevo' | proveedor-a-editar
const [seleccionado, setSeleccionado] = useState(null);
const [entradasProveedor, setEntradasProveedor] = useState([]);
async function crear(datos) {
await DB.put('proveedores', { id: DB.uid(), ...datos });
toast('Proveedor creado.');
setModal(null);
recargar();
}
async function actualizar(datos) {
await DB.put('proveedores', { ...modal, ...datos });
toast('Proveedor actualizado.');
setModal(null);
recargar();
}
async function alternarActivo(p) {
await DB.put('proveedores', { ...p, activo: !p.activo });
recargar();
}
useEffect(() => {
if (!seleccionado) return;
DB.queryIndex('entradas', 'proveedorId', seleccionado.id).then((es) => setEntradasProveedor(es.sort((a, b) => b.fechaRecepcion.localeCompare(a.fechaRecepcion))));
}, [seleccionado]);
if (seleccionado) {
const total = entradasProveedor.filter((e) => e.estado !== 'Anulada').reduce((s, e) => s + e.total, 0);
return (
setSeleccionado(null)} className="flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700">
Volver a proveedores
{seleccionado.razonSocial}
NIT {seleccionado.nit} · {seleccionado.direccion}{seleccionado.ciudad ? `, ${seleccionado.ciudad}` : ''}
Entradas registradas
{entradasProveedor.length}
Total comprado
{DB.money(total)}
Historial de entradas
{entradasProveedor.length === 0 ? (
Sin entradas registradas todavía.
) : (
Consecutivo Fecha Soporte Total Estado
{entradasProveedor.map((e) => (
onVerEntrada && onVerEntrada(e)}>
{e.consecutivo}
{DB.fechaCO(e.fechaRecepcion)}
{e.tipoSoporte} {e.numeroFactura || e.numeroRemision || e.numeroPedido}
{DB.money(e.total)}
{e.estado === 'Anulada' ? Anulada : Activa }
))}
)}
);
}
return (
Proveedores
{proveedores.length} registrados
setModal('nuevo')} className="bg-green-700 hover:bg-green-800 text-white px-4 py-2.5 rounded-lg text-sm font-bold flex items-center gap-2">
Nuevo proveedor
{proveedores.length === 0 ? (
) : (
Razón social NIT Teléfono Contacto
{proveedores.map((p) => (
setSeleccionado(p)}>{p.razonSocial}
{p.nit}
{p.telefono}
{p.contacto}
setModal(p)} className="text-xs text-slate-400 hover:text-green-700 mr-3">Editar
alternarActivo(p)} className="text-xs text-slate-400 hover:text-red-600">{p.activo ? 'Inactivar' : 'Reactivar'}
))}
)}
{modal && (
{modal === 'nuevo' ? 'Nuevo proveedor' : 'Editar proveedor'}
setModal(null)} className="text-slate-400 hover:text-white">
)}
);
}
/* ============================================================================
Colaboradores — CRUD simple (hoja de vida completa llega en la Fase 4)
============================================================================ */
function ColaboradorForm({ colaborador, areas, onGuardar, onCancelar }) {
const [f, setF] = useState(
colaborador || { nombreCompleto: '', cedula: '', cargo: '', area: areas?.[0] || '', celular: '', correo: '', tallaCamisa: '', tallaPantalon: '', tallaCalzado: '', fechaIngreso: '', activo: true }
);
const campo = (k) => (e) => setF((s) => ({ ...s, [k]: e.target.value }));
const inputCls = 'w-full p-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-600 focus:border-green-600';
const labelCls = 'block text-xs font-semibold text-slate-600 mb-1';
function submit(e) {
e.preventDefault();
if (!f.nombreCompleto.trim()) return;
onGuardar(f);
}
return (
Fecha de ingreso
Cancelar
Guardar
);
}
/* ---------- Importar colaboradores por Excel ---------- */
const ENCABEZADOS_COLABORADORES = ['Nombre completo', 'Cédula', 'Cargo', 'Área', 'Celular', 'Correo', 'Talla camisa', 'Talla pantalón', 'Talla calzado', 'Fecha de ingreso (AAAA-MM-DD)'];
function parsearExcelColaboradores(rows) {
const row0 = Array.from(rows[0] || [], normTxt);
const idx = (textos) => row0.findIndex((c) => textos.some((t) => c.includes(t)));
const iNombre = idx(['NOMBRE']);
const iCedula = idx(['CEDULA']);
const iCargo = idx(['CARGO']);
const iArea = idx(['AREA']);
const iCelular = idx(['CELULAR', 'TELEFONO']);
const iCorreo = idx(['CORREO', 'EMAIL']);
const iTallaCamisa = idx(['TALLA CAMISA']);
const iTallaPantalon = idx(['TALLA PANTALON']);
const iTallaCalzado = idx(['TALLA CALZADO']);
const iFecha = idx(['FECHA']);
if (iNombre === -1) throw new Error('No se encontró la columna "Nombre completo" en la fila de encabezados.');
const filas = [];
for (let i = 1; i < rows.length; i++) {
const row = rows[i] || [];
const nombreCompleto = row[iNombre];
if (!nombreCompleto || String(nombreCompleto).trim() === '') continue;
let fechaIngreso = '';
if (iFecha !== -1 && row[iFecha] !== undefined && row[iFecha] !== '') {
const v = row[iFecha];
if (typeof v === 'number') {
const ms = Math.round((v - 25569) * 86400 * 1000);
fechaIngreso = new Date(ms).toISOString().slice(0, 10);
} else {
fechaIngreso = String(v).trim();
}
}
filas.push({
nombreCompleto: String(nombreCompleto).trim(),
cedula: iCedula !== -1 ? String(row[iCedula] ?? '').trim() : '',
cargo: iCargo !== -1 ? String(row[iCargo] ?? '').trim() : '',
area: iArea !== -1 ? String(row[iArea] ?? '').trim() : '',
celular: iCelular !== -1 ? String(row[iCelular] ?? '').trim() : '',
correo: iCorreo !== -1 ? String(row[iCorreo] ?? '').trim() : '',
tallaCamisa: iTallaCamisa !== -1 ? String(row[iTallaCamisa] ?? '').trim() : '',
tallaPantalon: iTallaPantalon !== -1 ? String(row[iTallaPantalon] ?? '').trim() : '',
tallaCalzado: iTallaCalzado !== -1 ? String(row[iTallaCalzado] ?? '').trim() : '',
fechaIngreso,
});
}
return filas;
}
function descargarPlantillaColaboradores() {
const wb = window.XLSX.utils.book_new();
const ws = window.XLSX.utils.aoa_to_sheet([
ENCABEZADOS_COLABORADORES,
['Juan Pérez Gómez', '1234567890', 'Operario de bodega', 'Operaciones', '3001234567', 'juan.perez@ecocosecha.com', 'M', '34', '40', '2026-01-15'],
]);
ws['!cols'] = [{ wch: 26 }, { wch: 14 }, { wch: 20 }, { wch: 16 }, { wch: 14 }, { wch: 26 }, { wch: 12 }, { wch: 14 }, { wch: 12 }, { wch: 22 }];
window.XLSX.utils.book_append_sheet(wb, ws, 'Colaboradores');
window.XLSX.writeFile(wb, 'Plantilla_Colaboradores_Inventario_Ecocosecha.xlsx');
}
function ImportarColaboradores({ colaboradores, areas, recargar, toast, onCancelar }) {
const [cargando, setCargando] = useState(false);
const [filas, setFilas] = useState(null);
const [guardando, setGuardando] = useState(false);
const fileRef = useRef(null);
function buscarExistente(fila) {
if (fila.cedula) {
const porCedula = colaboradores.find((c) => c.cedula && c.cedula === fila.cedula);
if (porCedula) return porCedula;
}
return colaboradores.find((c) => !c.cedula && normTxt(c.nombreCompleto) === normTxt(fila.nombreCompleto)) || null;
}
async function elegirArchivo(file) {
if (!file) return;
setCargando(true);
try {
const buf = await file.arrayBuffer();
const wb = window.XLSX.read(buf, { type: 'array' });
const rows = window.XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, raw: true });
const parseadas = parsearExcelColaboradores(rows);
if (!parseadas.length) throw new Error('No se encontró ninguna fila con nombre.');
const resueltas = parseadas.map((f) => {
const existente = buscarExistente(f);
return { ...f, key: DB.uid(), area: f.area || areas?.[0] || '', existenteId: existente?.id || null };
});
setFilas(resueltas);
toast(`${resueltas.length} fila(s) leídas del archivo.`);
} catch (e) {
toast('No se pudo leer el archivo: ' + e.message, 'error');
}
setCargando(false);
}
function cambiarArea(key, area) { setFilas((fs) => fs.map((f) => (f.key === key ? { ...f, area } : f))); }
const creaciones = filas ? filas.filter((f) => !f.existenteId).length : 0;
const actualizaciones = filas ? filas.filter((f) => f.existenteId).length : 0;
async function confirmar() {
setGuardando(true);
try {
for (const f of filas) {
const datos = {
nombreCompleto: f.nombreCompleto, cedula: f.cedula, cargo: f.cargo, area: f.area,
celular: f.celular, correo: f.correo, tallaCamisa: f.tallaCamisa, tallaPantalon: f.tallaPantalon,
tallaCalzado: f.tallaCalzado, fechaIngreso: f.fechaIngreso, activo: true,
};
if (f.existenteId) await DB.put('colaboradores', { id: f.existenteId, ...datos });
else await DB.put('colaboradores', { id: DB.uid(), ...datos });
}
toast(`Importación lista: ${creaciones} colaborador(es) creado(s), ${actualizaciones} actualizado(s).`);
await recargar();
onCancelar();
} catch (e) {
toast('No se pudo completar la importación: ' + e.message, 'error');
}
setGuardando(false);
}
return (
Importar colaboradores desde Excel
{!filas ? (
Sube un Excel con una fila por colaborador usando la plantilla, o descárgala primero.
fileRef.current?.click()} className="bg-green-700 hover:bg-green-800 disabled:opacity-60 text-white px-4 py-2.5 rounded-lg text-sm font-bold">
{cargando ? 'Leyendo…' : 'Elegir archivo .xlsx'}
Descargar plantilla
elegirArchivo(e.target.files?.[0])} />
) : (
<>
{filas.length} fila(s) leídas
{creaciones} se crearán
{actualizaciones > 0 && {actualizaciones} actualizarán uno existente }
Nombre Cédula
Cargo Área Acción
{filas.map((f) => (
{f.nombreCompleto}
{f.cedula || '—'}
{f.cargo || '—'}
cambiarArea(f.key, e.target.value)} className="border border-slate-300 rounded px-2 py-1 text-xs bg-white">
— sin área —
{(areas || []).map((a) => {a} )}
{f.existenteId ? (
Actualizará existente
) : (
Crear nuevo
)}
))}
setFilas(null)} className="px-4 py-2 text-sm rounded-lg border border-slate-300 text-slate-600 hover:bg-slate-50">Elegir otro archivo
{guardando ? 'Importando…' : `Importar ${filas.length} colaborador(es)`}
>
)}
);
}
function Colaboradores({ colaboradores, config, recargar, toast }) {
const [modal, setModal] = useState(null); // null | 'nuevo' | colaborador-a-editar
const [importando, setImportando] = useState(false);
const [busqueda, setBusqueda] = useState('');
async function crear(datos) {
await DB.put('colaboradores', { id: DB.uid(), ...datos });
toast('Colaborador creado.');
setModal(null);
recargar();
}
async function actualizar(datos) {
await DB.put('colaboradores', { ...modal, ...datos });
toast('Colaborador actualizado.');
setModal(null);
recargar();
}
async function alternarActivo(c) {
await DB.put('colaboradores', { ...c, activo: !c.activo });
recargar();
}
const q = busqueda.trim().toLowerCase();
const filtrados = colaboradores.filter((c) => !q || `${c.nombreCompleto} ${c.cedula} ${c.cargo}`.toLowerCase().includes(q));
return (
Colaboradores
{colaboradores.length} registrados
setImportando(true)} className="bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 px-4 py-2.5 rounded-lg text-sm font-bold flex items-center gap-2">
Importar Excel
setModal('nuevo')} className="bg-green-700 hover:bg-green-800 text-white px-4 py-2.5 rounded-lg text-sm font-bold flex items-center gap-2">
Nuevo colaborador
{filtrados.length === 0 ? (
) : (
Nombre Cédula Cargo Área
{filtrados.map((c) => (
{c.nombreCompleto}
{c.cedula}
{c.cargo}
{c.area}
setModal(c)} className="text-xs text-slate-400 hover:text-green-700 mr-3">Editar
alternarActivo(c)} className="text-xs text-slate-400 hover:text-red-600">{c.activo ? 'Inactivar' : 'Reactivar'}
))}
)}
{modal && (
{modal === 'nuevo' ? 'Nuevo colaborador' : 'Editar colaborador'}
setModal(null)} className="text-slate-400 hover:text-white">
setModal(null)} />
)}
{importando && (
setImportando(false)}
/>
)}
);
}
/* ============================================================================
Requisiciones de insumos — llegan desde ../requisiciones/ (panel público,
sin login) a través de Supabase. Aquí solo se revisan y se les cambia el
estado; crear la Entrega o Entrega correspondiente se sigue haciendo
manualmente desde los módulos de Entradas/Entregas.
============================================================================ */
const CATEGORIAS_REQUISICION = { P: 'Papelería', C: 'Cafetería', H: 'Herramientas y Equipos', O: 'Otro' };
const ESTADOS_REQUISICION = ['Pendiente', 'Parcial', 'Entregada', 'Rechazada'];
const COLOR_ESTADO_REQUISICION = { Pendiente: '#b45309', Parcial: '#2563eb', Entregada: '#16a34a', Rechazada: '#dc2626' };
function RequisicionDetalle({ requisicion, lineas, onVolver, onGuardar, toast }) {
const [estado, setEstado] = useState(requisicion.estado);
const [autorizadoPor, setAutorizadoPor] = useState(requisicion.autorizado_por || '');
const [fechaEntrega, setFechaEntrega] = useState(requisicion.fecha_entrega || '');
const [observaciones, setObservaciones] = useState(requisicion.observaciones || '');
const [guardando, setGuardando] = useState(false);
async function guardar() {
setGuardando(true);
try {
await onGuardar(requisicion.id, { estado, autorizado_por: autorizadoPor, fecha_entrega: fechaEntrega || null, observaciones });
toast('Requisición actualizada.');
} catch (e) {
toast('No se pudo guardar: ' + e.message, 'error');
}
setGuardando(false);
}
const inputCls = 'w-full p-2.5 border border-slate-300 rounded-lg text-sm';
const labelCls = 'block text-xs font-semibold text-slate-600 mb-1';
return (
Volver a requisiciones
Requisición N.° {requisicion.numero}
{requisicion.estado}
Fecha solicitud
{DB.fechaCO(requisicion.fecha_solicitud)}
Área / Proceso
{requisicion.area || '—'}
Solicitado por
{requisicion.solicitado_por}
Recibida
{DB.fechaCO(requisicion.fecha_creacion?.slice(0, 10))}
# Categoría
Descripción del insumo Cant.
Unidad Observaciones
{lineas.map((l, i) => (
{i + 1}
{CATEGORIAS_REQUISICION[l.categoria] || l.categoria || '—'}
{l.descripcion}
{l.cantidad}
{l.unidad}
{l.observaciones}
))}
Gestión
Observaciones internas setObservaciones(e.target.value)} />
{guardando ? 'Guardando…' : 'Guardar'}
);
}
function Requisiciones({ toast }) {
const [cargando, setCargando] = useState(true);
const [error, setError] = useState('');
const [requisiciones, setRequisiciones] = useState([]);
const [detallePorRequisicion, setDetallePorRequisicion] = useState({});
const [seleccionada, setSeleccionada] = useState(null);
const [filtroEstado, setFiltroEstado] = useState('');
async function cargar() {
setCargando(true);
setError('');
try {
if (!window.SB) throw new Error('No se pudo conectar con el servidor de requisiciones.');
const [cabeceras, detalles] = await Promise.all([
window.SB.crudSelect('inv_requisiciones', { order: { col: 'fecha_creacion', asc: false } }),
window.SB.crudSelect('inv_requisicion_detalle'),
]);
const porRequisicion = {};
for (const d of detalles) (porRequisicion[d.requisicion_id] ||= []).push(d);
setRequisiciones(cabeceras);
setDetallePorRequisicion(porRequisicion);
} catch (e) {
setError(e.message || 'No se pudieron cargar las requisiciones.');
}
setCargando(false);
}
useEffect(() => { cargar(); }, []);
async function guardarGestion(id, patch) {
await window.SB.crudUpdate('inv_requisiciones', id, patch);
await cargar();
setSeleccionada((s) => (s ? { ...s, ...patch } : s));
}
if (seleccionada) {
return (
setSeleccionada(null)}
onGuardar={guardarGestion}
toast={toast}
/>
);
}
const filtradas = requisiciones.filter((r) => !filtroEstado || r.estado === filtroEstado);
const pendientes = requisiciones.filter((r) => r.estado === 'Pendiente').length;
return (
Requisiciones de insumos
{requisiciones.length} recibidas{pendientes > 0 && · {pendientes} pendiente(s) }
Actualizar
Las envían los colaboradores desde el formulario público de requisición de insumos. Aquí solo se revisan y se
les cambia el estado — para entregar los insumos, regístralo aparte en el módulo de Entregas.
{error ? (
{error}
) : cargando ? (
Cargando…
) : (
<>
setFiltroEstado(e.target.value)} className="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
Cualquier estado
{ESTADOS_REQUISICION.map((e) => {e} )}
{filtradas.length === 0 ? (
) : (
N.° Fecha
Área Solicitado por
Líneas Estado
{filtradas.map((r) => (
setSeleccionada(r)}>
{r.numero}
{DB.fechaCO(r.fecha_solicitud)}
{r.area || '—'}
{r.solicitado_por}
{(detallePorRequisicion[r.id] || []).length}
{r.estado}
))}
)}
>
)}
);
}
/* ============================================================================
Configuración
============================================================================ */
function Configuracion({ config, categorias, recargar, toast }) {
const [f, setF] = useState(config);
const [nuevaCategoria, setNuevaCategoria] = useState('');
const [confirmarVaciar, setConfirmarVaciar] = useState(false);
const fileRef = useRef(null);
useEffect(() => setF(config), [config]);
async function guardar() {
await DB.guardarConfiguracion(f);
toast('Configuración guardada.');
recargar();
}
async function agregarCategoria() {
const nombre = nuevaCategoria.trim();
if (!nombre) return;
await DB.put('categorias', { id: DB.uid(), nombre, color: '#16a34a', icono: 'box', activa: true });
setNuevaCategoria('');
toast('Categoría agregada.');
recargar();
}
async function alternarCategoria(cat) {
await DB.put('categorias', { ...cat, activa: !cat.activa });
recargar();
}
async function agregarArea(nombre) {
if (!nombre.trim()) return;
await DB.guardarConfiguracion({ areas: [...(f.areas || []), nombre.trim()] });
recargar();
}
async function quitarArea(area) {
await DB.guardarConfiguracion({ areas: (f.areas || []).filter((a) => a !== area) });
recargar();
}
async function exportar() {
const respaldo = await DB.exportarRespaldo();
const blob = new Blob([JSON.stringify(respaldo)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `respaldo_inventario_ecocosecha_${DB.hoyBogota()}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 4000);
toast('Respaldo descargado.');
}
async function restaurar(file) {
if (!file) return;
try {
const texto = await file.text();
const respaldo = JSON.parse(texto);
await DB.restaurarRespaldo(respaldo);
toast('Respaldo restaurado. Recargando…');
setTimeout(() => window.location.reload(), 1000);
} catch (e) {
toast('No se pudo restaurar: ' + e.message, 'error');
}
}
async function vaciarTodo() {
await DB.vaciarTodo();
setConfirmarVaciar(false);
window.location.reload();
}
const inputCls = 'w-full p-2.5 border border-slate-300 rounded-lg text-sm';
const labelCls = 'block text-xs font-semibold text-slate-600 mb-1';
if (!f) return null;
return (
Configuración
Datos de la empresa, formato del acta, categorías, áreas y respaldo.
Acta de entrega
Texto de la declaración
setF({ ...f, actaDeclaracion: e.target.value })} />
Áreas de la empresa
{(f.areas || []).map((a) => (
{a}
quitarArea(a)} className="text-slate-400 hover:text-red-600">
))}
Guardar configuración
Zona de riesgo
Borra absolutamente todos los datos de prueba (productos, entradas, entregas, kardex, configuración) para volver a empezar.
setConfirmarVaciar(true)} className="bg-white border border-red-300 text-red-600 hover:bg-red-100 px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2">
Borrar todos los datos
setConfirmarVaciar(false)}
onConfirmar={vaciarTodo}
/>
);
}
function AreaForm({ onAgregar }) {
const [v, setV] = useState('');
return (
setV(e.target.value)} placeholder="Nueva área" className="w-full p-2.5 border border-slate-300 rounded-lg text-sm" />
{ onAgregar(v); setV(''); }} className="bg-slate-800 text-white px-3 rounded-lg text-sm shrink-0">Agregar
);
}
/* ============================================================================
Placeholder de módulos que llegan en fases siguientes
============================================================================ */
function EnConstruccion({ modulo }) {
const NOMBRES = {
entradas: ['Entradas', 'Fase 2: cargar remisiones/facturas e importar el Excel del proveedor.'],
entregas: ['Entregas', 'Fase 3: generar el acta de entrega, imprimirla y firmarla.'],
colaboradores: ['Colaboradores', 'Fase 4: CRUD, importación desde Excel y hoja de vida de dotación.'],
ajustes: ['Ajustes de inventario', 'Fase 4: conteo físico y bajas por daño, vencimiento o pérdida.'],
proveedores: ['Proveedores', 'Fase 2: CRUD e historial de compras por proveedor.'],
reportes: ['Reportes', 'Fase 4: inventario valorizado, kardex, consumo y sugerido de pedido.'],
};
const [titulo, desc] = NOMBRES[modulo] || [modulo, ''];
return (
);
}
// Mantiene al día los espejos de solo lectura que usa el formulario público
// de Requisiciones (../requisiciones/) para que el colaborador elija su
// nombre y el insumo de una lista en vez de escribirlos a mano. La fuente de
// verdad sigue siendo IndexedDB — esto solo empuja lo necesario a Supabase
// cada vez que Inventario carga o cambian colaboradores/productos. Falla en
// silencio (consola, no toast) porque nunca debe bloquear el uso normal del
// inventario si Supabase no está disponible por algún motivo.
async function sincronizarReferenciasPublicas(colaboradores, productos) {
if (!window.SB) return;
try {
const colabActivos = colaboradores.filter((c) => c.activo).map((c) => ({ id: c.id, nombre_completo: c.nombreCompleto, cedula: c.cedula || null }));
const prodActivos = productos.filter((p) => p.activo).map((p) => ({ id: p.id, descripcion: p.descripcion, unidad: p.unidadEntrega, categoria: p.categoriaId || null }));
if (colabActivos.length) await window.SB.client.from('inv_colaboradores_pub').upsert(colabActivos, { onConflict: 'id' });
if (colabActivos.length) await window.SB.client.from('inv_colaboradores_pub').delete().not('id', 'in', `(${colabActivos.map((c) => c.id).join(',')})`);
else await window.SB.client.from('inv_colaboradores_pub').delete().not('id', 'is', null);
if (prodActivos.length) await window.SB.client.from('inv_productos_pub').upsert(prodActivos, { onConflict: 'id' });
if (prodActivos.length) await window.SB.client.from('inv_productos_pub').delete().not('id', 'in', `(${prodActivos.map((p) => p.id).join(',')})`);
else await window.SB.client.from('inv_productos_pub').delete().not('id', 'is', null);
} catch (e) {
console.error('No se pudo sincronizar las referencias públicas de Requisiciones:', e);
}
}
/* ============================================================================
App raíz
============================================================================ */
function App() {
const { toast, ToastHost } = useToasts();
const [listo, setListo] = useState(false);
const [necesitaSemilla, setNecesitaSemilla] = useState(false);
const [vista, setVista] = useState('panel');
const [config, setConfig] = useState(null);
const [categorias, setCategorias] = useState([]);
const [productos, setProductos] = useState([]);
const [proveedores, setProveedores] = useState([]);
const [colaboradores, setColaboradores] = useState([]);
const [abrirNuevo, setAbrirNuevo] = useState(null); // 'entradas' | 'entregas' | null
async function cargarTodo() {
const [cfg, cats, prods, provs, colabs] = await Promise.all([DB.getConfiguracion(), DB.getAll('categorias'), DB.getAll('productos'), DB.getAll('proveedores'), DB.getAll('colaboradores')]);
setConfig(cfg); setCategorias(cats); setProductos(prods); setProveedores(provs); setColaboradores(colabs);
sincronizarReferenciasPublicas(colabs, prods);
}
async function recargarProveedores() { setProveedores(await DB.getAll('proveedores')); }
async function recargarColaboradores() {
const colabs = await DB.getAll('colaboradores');
setColaboradores(colabs);
sincronizarReferenciasPublicas(colabs, productos);
}
function irAConAccion(destino) { setVista(destino); setAbrirNuevo(destino); }
useEffect(() => {
(async () => {
const vacio = await DB.estaVacia();
if (vacio) { setNecesitaSemilla(true); setListo(true); return; }
await cargarTodo();
setListo(true);
})();
}, []);
if (!listo) {
return
;
}
if (necesitaSemilla) {
return (
<>
{ setNecesitaSemilla(false); await cargarTodo(); }} />
>
);
}
return (
{vista === 'panel' && }
{vista === 'productos' && }
{vista === 'entradas' && (
setAbrirNuevo(null)}
/>
)}
{vista === 'entregas' && (
setAbrirNuevo(null)}
/>
)}
{vista === 'requisiciones' && }
{vista === 'colaboradores' && }
{vista === 'proveedores' && }
{vista === 'configuracion' && config && }
{['ajustes', 'reportes'].includes(vista) && }
);
}
createRoot(document.getElementById('app')).render( );