// ============================================================ // 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 (
Libros en conversión
{pendientes.map(l=>)}{enCurso.map(l=>)}
#{l.cons}{l.titulo}
#{l.cons}{l.titulo}
setConvirtiendo(null)} title="Actualizar formatos" footer={<>}>
{FORMATOS.map(f=>)}
); }; 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
{state.status==='loading'&&} {state.status==='loaded'&&state.available.length===0&&} {state.available.map(item=>)}
Núm.Serie / TítuloAutorFecha aprobaciónAcción
#{item.inventory_number||item.book_id}
{item.series_collection||'Sin serie'}
{item.title}
{item.author||'—'}{formatConversionDate(item.approved_at)}
Libros en conversión
Descargados desde REVISION · Sube los seis formatos al terminar
{state.inProgress.length===0&&} {state.inProgress.map(item=>)}
Núm.Serie / TítuloFechaEstadoAcción
#{item.inventory_number||item.book_id}
{item.series_collection||'Sin serie'}
{item.title}
{formatConversionDate(item.started_at||item.assigned_at)}En conversión
Libros finalizados ({state.finished.length})
Guardados en INPI 2026\FINALIZADO
{state.finished.map(item=>)}
Núm.SerieTítuloFecha finalizaciónStatus
#{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={<>}> {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={<> } > {uploadItem&&
#{uploadItem.inventory_number||uploadItem.book_id}
{uploadItem.title}
{uploadItem.series_collection||'Sin serie'} · {uploadItem.author||'Sin autor'}
Carga aislada por formato · endpoint v2
{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`:''}
}
}
; })}
{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 });