// ============================================================
// Módulo Conversión
// ============================================================
const CONVERSION_FORMATS = [
{ key: 'jpg', label: 'JPG', folder: '1.JPG', accept: 'image/jpeg,.jpg,.jpeg', extensions: ['jpg', 'jpeg'], multiple: true, help: 'Imágenes JPG o JPEG' },
{ key: 'tiff', label: 'TIFF', folder: '2.TIFF', accept: 'image/tiff,.tif,.tiff', extensions: ['tif', 'tiff'], multiple: true, help: 'Imágenes TIFF o TIF' },
{ key: 'pdf_color', label: 'PDF Color', folder: '3. PDF COLOR', accept: 'application/pdf,.pdf', extensions: ['pdf'], multiple: false, help: 'Un archivo PDF a color' },
{ key: 'pdf_bw', label: 'PDF B&N', folder: '4. PDF BYN', accept: 'application/pdf,.pdf', extensions: ['pdf'], multiple: false, help: 'Un archivo PDF en blanco y negro' },
{ key: 'epub', label: 'E-PUB', folder: '5.E-PUB', accept: 'application/x-msdownload,application/octet-stream,.exe', extensions: ['exe'], multiple: false, help: 'Un archivo ejecutable EXE para E-PUB' },
{ key: 'pdf_optimized', label: 'PDF optimizado', folder: '6.PDF OPTIMIZADO', accept: 'application/pdf,.pdf', extensions: ['pdf'], multiple: false, help: 'Un archivo PDF optimizado' },
];
const CONVERSION_UPLOAD_BATCH_SIZE = 50;
const conversionFileExtension = fileName => {
const parts = String(fileName || '').toLowerCase().split('.');
return parts.length > 1 ? parts.pop() : '';
};
const formatConversionDate = (value) => {
if (!value) return '—';
const date = new Date(String(value).replace(' ', 'T'));
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleDateString('es-MX');
};
const ConversionLocal = ({ libros, currentUser, onUpdate, onSelectBook }) => {
const FORMATOS = [
{ key: 'edicionJpg', label: 'Edición JPG', abrev: 'JPG' },
{ key: 'conversionTiff', label: 'Conversión TIFF', abrev: 'TIFF' },
{ key: 'pdfColor', label: 'PDF Color', abrev: 'PDF-C' },
{ key: 'pdfBn', label: 'PDF Blanco y Negro', abrev: 'PDF-BN' },
{ key: 'epub', label: 'E-PUB', abrev: 'EPUB' },
];
const [convirtiendo, setConvirtiendo] = useState(null);
const [formatos, setFormatos] = useState({});
const pendientes = libros.filter(l => l.estatus === 'aceptado');
const enCurso = libros.filter(l => l.estatus === 'en_conversion');
const finalizados = libros.filter(l => l.estatus === 'finalizado');
const iniciarConversion = libro => onUpdate(libro.cons, { estatus: 'en_conversion', fechaConversion: '2026-08-02' });
const abrirConversion = libro => { setConvirtiendo(libro); setFormatos(Object.fromEntries(FORMATOS.map(f => [f.key, Boolean(libro[f.key])]))); };
const guardarFormatos = () => {
if (!convirtiendo) return;
const todos = FORMATOS.every(f => formatos[f.key]);
onUpdate(convirtiendo.cons, { ...formatos, fechaConversion: convirtiendo.fechaConversion || '2026-08-02', estatus: todos ? 'finalizado' : 'en_conversion', statusFinal: todos ? 'FINALIZADO' : null });
setConvirtiendo(null);
};
return (
);
};
const ConversionServer = ({ currentUser, onSessionExpired }) => {
const [state, setState] = useState({ status: 'loading', error: '', available: [], inProgress: [], finished: [], stats: {} });
const [reloadToken, setReloadToken] = useState(0);
const [feedback, setFeedback] = useState(null);
const [claimBook, setClaimBook] = useState(null);
const [uploadItem, setUploadItem] = useState(null);
const [files, setFiles] = useState({});
const [uploadedFormats, setUploadedFormats] = useState({});
const [formatSaving, setFormatSaving] = useState({});
const [uploadProgress, setUploadProgress] = useState({});
const [saving, setSaving] = useState(false);
const conversionFilesRef = useRef({});
useEffect(() => {
let active = true;
const controller = new AbortController();
const load = async () => {
setState(previous => ({ ...previous, status: 'loading', error: '' }));
try {
const response = await window.apiRequest('conversion/dashboard.php', { signal: controller.signal });
if (!active) return;
const data = response?.data || {};
setState({ status: 'loaded', error: '', available: data.available || [], inProgress: data.in_progress || [], finished: data.finished || [], stats: data.stats || {} });
} catch (error) {
if (!active) return;
if (error?.status === 401) return onSessionExpired();
setState(previous => ({ ...previous, status: 'error', error: error?.message || 'No fue posible cargar Conversión.' }));
}
};
load();
return () => { active = false; controller.abort(); };
}, [reloadToken, onSessionExpired]);
const refresh = () => setReloadToken(value => value + 1);
const downloadZip = async (assignmentId) => {
const base = window.APP_CONFIG.API_BASE_URL.replace(/\/+$/, '');
const response = await fetch(`${base}/conversion/download.php?assignment_id=${encodeURIComponent(assignmentId)}`, { credentials: 'include' });
if (!response.ok) {
let message = `No fue posible descargar el ZIP. HTTP ${response.status}`;
try { const payload = await response.json(); message = payload.message || message; } catch (error) {}
throw new Error(message);
}
const blob = await response.blob();
const disposition = response.headers.get('content-disposition') || '';
const match = disposition.match(/filename\*=UTF-8''([^;]+)/i) || disposition.match(/filename="?([^";]+)"?/i);
const name = match ? decodeURIComponent(match[1]) : 'INPI_2026_CONVERSION.zip';
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url; link.download = name; document.body.appendChild(link); link.click(); link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const claimAndDownload = async () => {
if (!claimBook) return;
setSaving(true); setFeedback(null);
let assignmentId = null;
try {
const response = await window.apiRequest('conversion/claim.php', { method: 'POST', body: { book_id: Number(claimBook.book_id) } });
assignmentId = Number(response?.data?.assignment_id);
await downloadZip(assignmentId);
setClaimBook(null);
setFeedback({ tone: 'success', message: 'Libro tomado y ZIP descargado desde REVISION.' });
refresh();
} catch (error) {
if (assignmentId) {
try { await window.apiRequest('conversion/release.php', { method: 'POST', body: { assignment_id: assignmentId } }); } catch (releaseError) {}
}
if (error?.status === 401) return onSessionExpired();
setFeedback({ tone: 'error', message: error?.message || 'No fue posible tomar y descargar el libro.' });
refresh();
} finally { setSaving(false); }
};
const openUpload = item => {
const currentUploaded = {};
CONVERSION_FORMATS.forEach(format => {
currentUploaded[format.key] = Number(item[`${format.key}_count`] || 0);
});
setUploadItem(item);
conversionFilesRef.current = {};
setFiles({});
setUploadedFormats(currentUploaded);
setFormatSaving({});
setUploadProgress({});
setFeedback(null);
};
const selectedFilesFor = formatKey => {
const selected = conversionFilesRef.current[formatKey];
return Array.isArray(selected) ? selected : [];
};
const validateFormatFiles = format => {
const selectedFiles = selectedFilesFor(format.key);
if (selectedFiles.length === 0) {
throw new Error(`Selecciona ${format.help.toLowerCase()} para ${format.label}.`);
}
const invalidFile = selectedFiles.find(file => !format.extensions.includes(conversionFileExtension(file.name)));
if (invalidFile) {
throw new Error(`${invalidFile.name} no corresponde al formato ${format.label}.`);
}
if (!format.multiple && selectedFiles.length > 1) {
throw new Error(`Para ${format.label} selecciona solamente un archivo.`);
}
return selectedFiles;
};
const uploadSingleFormat = async format => {
if (!uploadItem) return;
let selectedFiles = [];
try {
selectedFiles = validateFormatFiles(format);
} catch (error) {
setFeedback({ tone: 'error', message: error.message });
return;
}
const batches = [];
for (let index = 0; index < selectedFiles.length; index += CONVERSION_UPLOAD_BATCH_SIZE) {
batches.push(selectedFiles.slice(index, index + CONVERSION_UPLOAD_BATCH_SIZE));
}
const uploadSessionId = `${uploadItem.assignment_id}-${format.key}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
setFormatSaving(previous => ({ ...previous, [format.key]: true }));
setUploadProgress(previous => ({
...previous,
[format.key]: { currentBatch: 0, totalBatches: batches.length, uploadedFiles: 0, totalFiles: selectedFiles.length }
}));
setFeedback(null);
try {
let resultingCount = 0;
let uploadedFiles = 0;
for (let batchIndex = 0; batchIndex < batches.length; batchIndex += 1) {
const batch = batches[batchIndex];
const formData = new FormData();
formData.append('assignment_id', String(uploadItem.assignment_id));
formData.append('format_key', format.key);
formData.append('upload_session_id', uploadSessionId);
formData.append('batch_index', String(batchIndex));
formData.append('total_batches', String(batches.length));
formData.append('total_files', String(selectedFiles.length));
batch.forEach(file => formData.append('files[]', file, file.name));
const base = window.APP_CONFIG.API_BASE_URL.replace(/\/+$/, '');
const rawResponse = await fetch(`${base}/conversion/upload-format-v2.php`, {
method: 'POST',
credentials: 'include',
body: formData,
cache: 'no-store'
});
let response;
try {
response = await rawResponse.json();
} catch (parseError) {
throw Object.assign(new Error(`Respuesta inválida del servidor. HTTP ${rawResponse.status}`), {
status: rawResponse.status
});
}
if (!rawResponse.ok || response?.success === false) {
throw Object.assign(new Error(response?.message || `No fue posible subir el lote. HTTP ${rawResponse.status}`), {
status: rawResponse.status
});
}
uploadedFiles += batch.length;
resultingCount = Number(response?.data?.count || uploadedFiles);
setUploadProgress(previous => ({
...previous,
[format.key]: {
currentBatch: batchIndex + 1,
totalBatches: batches.length,
uploadedFiles,
totalFiles: selectedFiles.length
}
}));
}
setUploadedFormats(previous => ({ ...previous, [format.key]: resultingCount }));
delete conversionFilesRef.current[format.key];
setFiles(previous => {
const next = { ...previous };
delete next[format.key];
return next;
});
setFeedback({ tone: 'success', message: `${format.label} subido correctamente (${resultingCount} archivo${resultingCount === 1 ? '' : 's'}).` });
setUploadItem(previous => previous ? { ...previous, [`${format.key}_count`]: resultingCount } : previous);
refresh();
} catch (error) {
if (error?.status === 401) return onSessionExpired();
setFeedback({ tone: 'error', message: error?.message || `No fue posible subir ${format.label}. La carga puede reintentarse completa.` });
} finally {
setFormatSaving(previous => ({ ...previous, [format.key]: false }));
}
};
const allFormatsUploaded = CONVERSION_FORMATS.every(format => Number(uploadedFormats[format.key] || 0) > 0);
const saveOrFinalize = async () => {
if (!uploadItem) return;
if (!allFormatsUploaded) {
setUploadItem(null);
setFiles({});
setFeedback({ tone: 'success', message: 'Cambios guardados. Puedes continuar subiendo los formatos después.' });
refresh();
return;
}
setSaving(true);
setFeedback(null);
try {
await window.apiRequest('conversion/finalize.php', {
method: 'POST',
body: { assignment_id: Number(uploadItem.assignment_id) }
});
setUploadItem(null);
setFiles({});
setUploadedFormats({});
setFeedback({ tone: 'success', message: 'Todos los formatos se guardaron y el libro fue finalizado correctamente.' });
refresh();
} catch (error) {
if (error?.status === 401) return onSessionExpired();
setFeedback({ tone: 'error', message: error?.message || 'No fue posible finalizar la Conversión.' });
} finally {
setSaving(false);
}
};
return (
{feedback &&
{feedback.message}
}
Libros validados disponibles
Aprobados por Control de calidad y disponibles para tomar
Núm. Serie / Título Autor Fecha aprobación Acción
{state.status==='loading'&& }
{state.status==='loaded'&&state.available.length===0&& }
{state.available.map(item=>#{item.inventory_number||item.book_id} {item.series_collection||'Sin serie'}
{item.title}
{item.author||'—'} {formatConversionDate(item.approved_at)} setClaimBook(item)}>Tomar )}
Libros en conversión
Descargados desde REVISION · Sube los seis formatos al terminar
Núm. Serie / Título Fecha Estado Acción
{state.inProgress.length===0&& }
{state.inProgress.map(item=>#{item.inventory_number||item.book_id} {item.series_collection||'Sin serie'}
{item.title}
{formatConversionDate(item.started_at||item.assigned_at)} En conversión openUpload(item)}>Subir formatos )}
Libros finalizados ({state.finished.length})
Guardados en INPI 2026\FINALIZADO
Núm. Serie Título Fecha finalización Status
{state.finished.map(item=>#{item.inventory_number||item.book_id} {item.series_collection||'—'} {item.title} {formatConversionDate(item.completed_at)} Finalizado )}
!saving&&setClaimBook(null)} title="Tomar libro para Conversión" maxWidth={560} footer={<>setClaimBook(null)}>Cancelar {saving?'Preparando…':'Aceptar y descargar'} >}>
{claimBook&&#{claimBook.inventory_number||claimBook.book_id}
{claimBook.title}
{claimBook.series_collection||'Sin serie'}
Antes de descargar Crea esta estructura de carpeta para mantener el control:Documentos\INPI 2026\CONVERSION\ El ZIP se descargará desde la carpeta REVISION y comenzará desde Serie\Título\1.JPG.
}
!saving&&!Object.values(formatSaving).some(Boolean)&&setUploadItem(null)}
title="Subir formatos"
maxWidth={700}
footer={<>
setUploadItem(null)}
>
Cancelar
{saving ? 'Finalizando…' : (allFormatsUploaded ? 'Guardar y finalizar' : 'Guardar cambios')}
>}
>
{uploadItem&&
#{uploadItem.inventory_number||uploadItem.book_id}
{uploadItem.title}
{uploadItem.series_collection||'Sin serie'} · {uploadItem.author||'Sin autor'}
Carga aislada por formato · endpoint v2
Fecha de subida
{CONVERSION_FORMATS.map(format=>{
const uploadedCount=Number(uploadedFormats[format.key]||0);
const isUploaded=uploadedCount>0;
const isUploading=Boolean(formatSaving[format.key]);
const selectedCount=selectedFilesFor(format.key).length;
const progress=uploadProgress[format.key]||null;
return
{format.label} → {format.folder}
{format.help}
{isUploaded&&
✓ {format.label} subido correctamente{uploadedCount>1?` (${uploadedCount} archivos)`:''}
}
{!isUploaded&&<>
{
const selected = Array.from(event.currentTarget.files || []);
conversionFilesRef.current[format.key] = selected;
setFiles(previous=>({
...previous,
[format.key]: selected.map(file=>({
name:file.name,
size:file.size,
type:file.type,
lastModified:file.lastModified
}))
}));
}}
/>
Se guardará en INPI 2026\FINALIZADO\Serie\Título\{format.folder}\
{selectedCount>0?` · ${selectedCount} seleccionado${selectedCount===1?'':'s'}`:''}
{isUploading&&progress?` · Lote ${progress.currentBatch} de ${progress.totalBatches} · ${progress.uploadedFiles} de ${progress.totalFiles} archivos`:''}
{selectedCount>0&&
Selección actual: {selectedFilesFor(format.key).slice(0,3).map(file=>file.name).join(', ')}
{selectedCount>3?` y ${selectedCount-3} más`:''}
}
uploadSingleFormat(format)}
>
{isUploading?'Subiendo…':'Subir'}
>}
;
})}
{allFormatsUploaded
? 'Todos los formatos fueron subidos. Ya puedes guardar y finalizar.'
: `${CONVERSION_FORMATS.filter(format=>Number(uploadedFormats[format.key]||0)>0).length} de ${CONVERSION_FORMATS.length} formatos subidos. Puedes guardar cambios y continuar después.`}
}
);
};
const Conversion = (props) => {
const serverMode = window.APP_CONFIG.AUTH.MODE === 'server';
if (serverMode) return ;
return ;
};
Object.assign(window, { Conversion });