Equivale a {moneyI((parseFloat(f.total)||0)*(parseFloat(f.tc)||1))} MXN (Total × TC).
}
);
}
// ── Modal de alta manual de una OC (formulario simple, sin Excel) ─────────────
// Buscador de claves: filtra por clave o por descripcion y deja teclear una a mano.
// Muestra la clave IMPRESA del grupo cuando difiere de la del catalogo base, porque
// son distintas en varios grupos y confundirlas imputa el gasto a otro insumo.
function ClavePicker({ value, claves, disabled, onChange }) {
const [q, setQ] = useState('');
const [abierto, setAbierto] = useState(false);
const elegida = (claves || []).find(c => c.clave === value);
const filtradas = useMemo(() => {
const t = q.trim().toUpperCase();
const base = claves || [];
if (!t) return base.slice(0, 40);
return base.filter(c =>
(c.clave || '').toUpperCase().includes(t) ||
(c.clave_grupo || '').toUpperCase().includes(t) ||
(c.descripcion || '').toUpperCase().includes(t)
).slice(0, 40);
}, [q, claves]);
if (disabled) {
return ;
}
return (
La lectura con IA no está configurada en el servidor.
)}
setArchivos(Array.from(e.target.files || []))} />
Cada orden que encuentre abre su propia pestaña para revisarla.
La IA no guarda nada: al final tú decides si guardas una o todas.
)}
{vista === 'form' && (<>
{notas && (
Revisa: {notas}
)}
{borradores.length > 1 && (
{borradores.map((b, i) => (
))}
)}
{f._estado === 'guardado' && (
Esta OC ya se guardó. Los cambios de aquí no se vuelven a enviar.
)}
{f._error && (
No se pudo guardar: {f._error}
)}
{f._ia && (
Propuesto por la IA desde {f._ia.archivo || 'el PDF'} · confianza {f._ia.confianza}
{f._ia.clave_cruda && <> · propuso la clave {f._ia.clave_cruda}, que no está en la matriz del proyecto>}
)}
>)}
);
}
function ImportOCView({ onNav, user, onLogout }) {
const { data: proyectos } = useApiData('proyectos.php');
const fileRef = useRef(null);
const [preview, setPreview] = useState(null); // { rows, proyectos, count }
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
// Arranca en la tasa VIGENTE de Configuración, no en un 17 fijo: con el fijo se
// estampaba 17 en cada OC importada en dólares aunque la configurada fuera 17.50
// (ya hay 1 OC guardada con tc 17.0000 frente a 26 con 17.50). Si el usuario lo
// cambia a mano, su valor manda.
const [tcUsd, setTcUsd] = useState(''); // 1 USD = ? MXN (tipo de cambio)
const [tcTocado, setTcTocado] = useState(false);
const { data: tcData } = useApiData('tipo_cambio.php');
useEffect(() => {
if (!tcTocado && tcData?.tasas?.USD != null) setTcUsd(String(tcData.tasas.USD));
}, [tcData, tcTocado]);
const [empresa, setEmpresa] = useState('');
const [q, setQ] = useState('');
const [page, setPage] = useState(1);
const dq = useDebounce(q);
const { data: listData, loading, reload } = useApiData('oc_import.php', { empresa, q: dq, page, limit: 100 });
const rows = listData?.rows || [];
const resumen = listData?.resumen || [];
const pages = listData?.pages || 1;
const [editRow, setEditRow] = useState(null);
const [showNew, setShowNew] = useState(false);
useEffect(() => setPage(1), [empresa, dq]);
async function handleFile(e) {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file) return;
setUploading(true);
try {
const fd = new FormData(); fd.append('file', file);
const res = await fetch('api/oc_import.php', {
method: 'POST', credentials: 'same-origin',
headers: getCsrfToken() ? { 'X-CSRF-Token': getCsrfToken() } : {}, body: fd,
});
const json = await res.json();
if (!json.ok) throw new Error(json.error || 'Error al leer el archivo');
setPreview(json.data);
toast.success(`${json.data.count} OCs leídas. Revisa el mapeo y confirma.`);
} catch (err) { toast.error(err.message); }
finally { setUploading(false); }
}
const setPrevCell = (i, field, val) => setPreview(p => ({ ...p, rows: p.rows.map((r, idx) => idx === i ? { ...r, [field]: val } : r) }));
// Al reasignar el proyecto, la empresa y el nombre mapeado se toman de Contraloría.
function changePrevProyecto(i, val) {
const pid = val ? Number(val) : null;
const proj = pid ? (preview.proyectos || []).find(p => p.id === pid) : null;
setPreview(p => ({ ...p, rows: p.rows.map((r, idx) => idx === i
? { ...r, proyectoid: pid, proyecto_match: proj ? proj.nombre : '', empresa: proj ? proj.empresa : r.empresa }
: r) }));
}
async function commit() {
if (!preview || !preview.rows.length) return;
setSaving(true);
const tc = Number(tcUsd) || 1;
const rowsToSave = preview.rows.map(r => ({ ...r, tc: r.moneda === 'USD' ? tc : 1 }));
try {
const res = await apiFetch('oc_import.php', 'POST', { accion:'commit', rows: rowsToSave });
toast.success(`${res.guardadas} OCs guardadas`);
setPreview(null);
reload();
} catch (err) { toast.error(err.message); }
finally { setSaving(false); }
}
async function del(id) {
if (!window.confirm('¿Eliminar esta OC importada?')) return;
try { await apiFetch('oc_import.php?id=' + id, 'DELETE'); toast.success('Eliminada'); reload(); }
catch (e) { toast.error(e.message); }
}
async function downloadReporteEmpresa() {
try {
const res = await fetch('api/reporte_empresa.php', { credentials: 'same-origin' });
if (!res.ok) { toast.error('No se pudo generar el reporte'); return; }
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `Reporte_por_Empresa_${new Date().toISOString().slice(0,10)}.xlsx`; a.click();
URL.revokeObjectURL(url);
} catch (e) { toast.error(e.message); }
}
const lowCount = preview ? preview.rows.filter(r => (r.match_score ?? 0) < 60).length : 0;
return (
}/>
{/* ── PREVISUALIZACIÓN ── */}
{preview && (
Previsualización · {preview.count} OCs
Revisa el proyecto asignado (mapeo difuso). {lowCount > 0
? {lowCount} con baja confianza (<60%) — corrígelas.
: 'Todas con confianza aceptable.'} El folio se guardará con sufijo D.
1 USD =
setTcTocado(true)}
onChange={e => setTcUsd(e.target.value)} style={{ width:90 }} title="Tipo de cambio para convertir las OC en USD"/>
MXN