// ============================================================
// Módulo Digitalización
// ============================================================
const DIGITALIZATION_UPLOAD_BATCH_SIZE = 50;
const DIGITALIZATION_ASSIGNMENT_STATUS = Object.freeze({
pending: 'nuevo',
in_progress: 'en_digitalizacion',
completed: 'digitalizado',
cancelled: 'rechazado',
reassigned: 'rechazado'
});
const toIsoDate = (value) => {
if (!value) return '';
if (/^\d{4}-\d{2}-\d{2}$/.test(String(value))) return String(value);
const normalized = String(value).replace(' ', 'T');
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return '';
return date.toISOString().slice(0, 10);
};
const formatShortDate = (value) => {
const iso = toIsoDate(value);
if (!iso) return '—';
const [year, month, day] = iso.split('-');
return `${day}/${month}/${year}`;
};
const mapCurrentBookStatus = (stage, status) => {
switch (stage) {
case 'digitization':
return status === 'completed' ? 'digitalizado' : 'en_digitalizacion';
case 'editing':
return status === 'completed' ? 'editado' : 'en_edicion';
case 'quality_control':
if (status === 'rejected') return 'rechazado';
if (status === 'accepted' || status === 'completed') return 'aceptado';
return 'en_cc';
case 'conversion':
return status === 'completed' ? 'finalizado' : 'en_conversion';
case 'publication':
case 'published':
return 'finalizado';
default:
if (status === 'completed') return 'digitalizado';
if (status === 'in_progress') return 'en_digitalizacion';
return 'nuevo';
}
};
const ServerDigitalizationDetailModal = ({ item, currentUser, onClose, onSessionExpired }) => {
const [state, setState] = useState({ status: item ? 'loading' : 'idle', book: null, error: '' });
useEffect(() => {
if (!item?.book?.id) return undefined;
let active = true;
const controller = new AbortController();
const loadBook = async () => {
setState({ status: 'loading', book: null, error: '' });
try {
const response = await window.apiRequest(`books/show.php?id=${encodeURIComponent(item.book.id)}`, {
signal: controller.signal
});
if (!active) return;
setState({
status: 'loaded',
book: response && response.data ? response.data.book : null,
error: ''
});
} catch (error) {
if (!active) return;
if (error && error.status === 401) {
onClose();
onSessionExpired();
return;
}
setState({
status: 'error',
book: null,
error: error && error.message ? error.message : 'No fue posible cargar el detalle del libro.'
});
}
};
loadBook();
return () => {
active = false;
controller.abort();
};
}, [item?.book?.id, onClose, onSessionExpired]);
if (!item) return null;
const book = state.book || {};
const title = book.title || item.book?.title || 'Libro sin título';
const inventoryNumber = book.inventory_number || item.book?.inventory_number || item.book?.id || item.assignment_id;
const series = book.publisher || item.book?.series_collection || 'Sin serie';
const totalPages = book.total_pages || item.book?.total_pages || 0;
const barcode = book.barcode || '—';
const statusKey = mapCurrentBookStatus(item.book?.current_stage, item.book?.status);
return (
{state.status === 'loading' && (
)}
{state.status === 'error' && (
{state.error}
)}
{state.status !== 'loading' && state.status !== 'error' && (
Datos del libro
Núm. Consecutivo
#{inventoryNumber}
Serie o Colección
{item.book?.series_collection || '—'}
Autor
{book.author || item.book?.author || '—'}
Pie de imprenta
{book.publisher ? `${book.publisher}${book.publication_year ? `, ${book.publication_year}` : ''}` : '—'}
Páginas
{fmtNum(totalPages)}
Código de barras
{barcode}
Proceso de digitalización
Fecha
{formatShortDate(item.completed_at)}
Digitalizado por
{currentUser.nombre || currentUser.name || '—'}
Imágenes
{fmtNum(item.resulting_images || 0)}
Acumulado
{fmtNum(item.cumulative_images || 0)}
)}
);
};
const DigitalizacionLocal = ({ libros, currentUser, onUpdate, onSelectBook }) => {
const [formState, setFormState] = useState({
open: false,
mode: 'complete',
book: null,
images: '',
date: '2026-08-02',
error: ''
});
const misLibros = libros.filter(l =>
(l.estatus === 'en_digitalizacion' && l.digitalizadoPor === currentUser.id) || l.estatus === 'nuevo'
);
const misFinalizados = libros.filter(l =>
l.digitalizadoPor === currentUser.id &&
['digitalizado', 'en_edicion', 'editado', 'en_cc', 'aceptado', 'rechazado', 'en_conversion', 'finalizado'].includes(l.estatus)
);
const totalImagenesHoy = misFinalizados
.filter(l => l.fechaDigitalizacion === '2026-08-02')
.reduce((sum, l) => sum + (l.imagenesResultantes || 0), 0);
const iniciarDigitalizacion = (libro) => {
onUpdate(libro.cons, { estatus: 'en_digitalizacion', digitalizadoPor: currentUser.id });
};
const openCompleteModal = (libro) => {
setFormState({ open: true, mode: 'complete', book: libro, images: '', date: '2026-08-02', error: '' });
};
const openEditModal = (libro) => {
setFormState({
open: true,
mode: 'edit',
book: libro,
images: libro.imagenesResultantes ? String(libro.imagenesResultantes) : '',
date: libro.fechaDigitalizacion || '2026-08-02',
error: ''
});
};
const closeFormModal = () => {
setFormState({ open: false, mode: 'complete', book: null, images: '', date: '2026-08-02', error: '' });
};
const submitLocalForm = (event) => {
event.preventDefault();
if (!formState.book) return;
const images = parseInt(formState.images, 10);
if (isNaN(images) || images <= 0) {
setFormState(previous => ({ ...previous, error: 'Ingresa una cantidad válida de imágenes.' }));
return;
}
onUpdate(formState.book.cons, {
estatus: formState.mode === 'complete' ? 'digitalizado' : formState.book.estatus,
fechaDigitalizacion: formState.date,
digitalizadoPor: currentUser.id,
imagenesResultantes: images,
acumuladoImagenes: formState.mode === 'edit'
? (formState.book.acumuladoImagenes ? formState.book.acumuladoImagenes - (formState.book.imagenesResultantes || 0) + images : images)
: images,
});
closeFormModal();
};
return (
Mi cola de digitalización
Obras no digitalizadas disponibles y trabajos tomados por ti · Los libros de otros usuarios no aparecen
| Núm. |
Serie / Título |
Autor |
Págs. |
Estatus |
Acción |
{misLibros.length === 0 && (
|
)}
{misLibros.map(libro => (
| #{libro.cons} |
{libro.serie}
{libro.titulo}
|
{libro.autor} |
{libro.paginas} |
|
{libro.estatus === 'nuevo' ? (
) : (
)}
|
))}
Mis libros digitalizados
Últimos {Math.min(10, misFinalizados.length)} libros procesados
| Núm. |
Título |
Fecha |
Imágenes |
Acumulado |
Estatus actual |
Acciones |
{misFinalizados.length === 0 && (
|
)}
{misFinalizados.slice(-10).reverse().map(libro => (
| #{libro.cons} |
{libro.titulo} |
{fmtDate(libro.fechaDigitalizacion)} |
{fmtNum(libro.imagenesResultantes)} |
{fmtNum(libro.acumuladoImagenes)} |
|
|
))}
>}
>
{formState.book && (
)}
);
};
const DigitalizacionServer = ({ currentUser, onSessionExpired }) => {
const [state, setState] = useState({
status: 'loading',
error: '',
stats: { queue_count: 0, completed_total: 0, images_today: 0 },
queue: [],
queuePagination: { page: 1, per_page: 10, total: 0, total_pages: 0 },
recent: []
});
const [reloadToken, setReloadToken] = useState(0);
const [queueSearch, setQueueSearch] = useState('');
const [queueSearchInput, setQueueSearchInput] = useState('');
const [queuePage, setQueuePage] = useState(1);
const [queuePerPage, setQueuePerPage] = useState(10);
const [feedback, setFeedback] = useState(null);
const [operationById, setOperationById] = useState({});
const [detailItem, setDetailItem] = useState(null);
const [successModal, setSuccessModal] = useState({ open: false, title: '', message: '', path: '', count: 0 });
const [uploadProgress, setUploadProgress] = useState({
active: false,
uploaded: 0,
total: 0,
batch: 0,
totalBatches: 0,
percent: 0,
message: ''
});
const [formState, setFormState] = useState({
open: false,
mode: 'complete',
assignment: null,
images: '',
files: [],
imageOperation: 'append',
imageStatus: null,
statusLoading: false,
date: new Date().toISOString().slice(0, 10),
error: ''
});
useEffect(() => {
let active = true;
const controller = new AbortController();
const loadDashboard = async () => {
setState(previous => ({ ...previous, status: 'loading', error: '' }));
try {
const query = new URLSearchParams({
page: String(queuePage),
per_page: String(queuePerPage)
});
if (queueSearch) query.set('search', queueSearch);
const response = await window.apiRequest(`digitalization/dashboard.php?${query.toString()}`, { signal: controller.signal });
if (!active) return;
const data = response && response.data ? response.data : {};
setState({
status: 'loaded',
error: '',
stats: data.stats || { queue_count: 0, completed_total: 0, images_today: 0 },
queue: Array.isArray(data.queue_items) ? data.queue_items : [],
queuePagination: data.queue_pagination || { page: queuePage, per_page: queuePerPage, total: 0, total_pages: 0 },
recent: Array.isArray(data.recent_completed) ? data.recent_completed : []
});
} catch (error) {
if (!active) return;
if (error && error.status === 401) {
onSessionExpired();
return;
}
setState(previous => ({
...previous,
status: 'error',
error: error && error.message ? error.message : 'No fue posible obtener los datos de digitalización.'
}));
}
};
loadDashboard();
return () => {
active = false;
controller.abort();
};
}, [reloadToken, queueSearch, queuePage, queuePerPage, onSessionExpired]);
const submitQueueSearch = (event) => {
event.preventDefault();
setQueuePage(1);
setQueueSearch(queueSearchInput.trim());
};
const clearQueueSearch = () => {
setQueueSearchInput('');
setQueueSearch('');
setQueuePage(1);
};
const changeQueuePerPage = (value) => {
setQueuePerPage(Number(value));
setQueuePage(1);
};
const refreshData = () => setReloadToken(value => value + 1);
const setOperation = (assignmentId, value) => {
setOperationById(previous => ({ ...previous, [assignmentId]: value }));
};
const clearOperation = (assignmentId) => {
setOperationById(previous => {
const next = { ...previous };
delete next[assignmentId];
return next;
});
};
const startAssignment = async (assignment) => {
const assignmentId = assignment.assignment_id;
if (!assignmentId) return;
setFeedback(null);
setOperation(assignmentId, 'starting');
try {
await window.assignmentsService.startAssignment(assignmentId);
setFeedback({ tone: 'success', message: 'La digitalización se inició correctamente.' });
refreshData();
} catch (error) {
if (error && error.status === 401) {
onSessionExpired();
return;
}
setFeedback({ tone: 'error', message: error && error.message ? error.message : 'No fue posible iniciar la digitalización.' });
} finally {
clearOperation(assignmentId);
}
};
const claimAvailableBook = async (item) => {
const bookId = item?.book?.id;
if (!bookId) return;
const operationKey = `book-${bookId}`;
setFeedback(null);
setOperation(operationKey, 'claiming');
try {
await window.apiRequest('digitalization/claim.php', {
method: 'POST',
body: { book_id: bookId }
});
setFeedback({ tone: 'success', message: 'El libro se agregó a tu cola y la digitalización quedó iniciada.' });
refreshData();
} catch (error) {
if (error && error.status === 401) {
onSessionExpired();
return;
}
if (error && error.status === 409) {
setFeedback({ tone: 'warning', message: error.message || 'Otro usuario ya tomó este libro.' });
refreshData();
return;
}
setFeedback({ tone: 'error', message: error && error.message ? error.message : 'No fue posible tomar el libro.' });
} finally {
clearOperation(operationKey);
}
};
const openCompleteModal = (assignment) => {
setFeedback(null);
setUploadProgress({ active: false, uploaded: 0, total: 0, batch: 0, totalBatches: 0, percent: 0, message: '' });
setFormState({
open: true,
mode: 'complete',
assignment,
images: '',
files: [],
imageOperation: 'append',
imageStatus: null,
statusLoading: false,
date: new Date().toISOString().slice(0, 10),
error: ''
});
};
const openEditModal = async (assignment) => {
setFeedback(null);
setUploadProgress({ active: false, uploaded: 0, total: 0, batch: 0, totalBatches: 0, percent: 0, message: '' });
setFormState({
open: true,
mode: 'edit',
assignment,
images: assignment.resulting_images ? String(assignment.resulting_images) : '',
files: [],
imageOperation: 'append',
imageStatus: null,
statusLoading: true,
date: toIsoDate(assignment.completed_at) || new Date().toISOString().slice(0, 10),
error: ''
});
try {
const response = await window.apiRequest(
`digitalization/edit-status.php?assignment_id=${encodeURIComponent(assignment.assignment_id)}`
);
const status = response && response.data ? response.data : null;
setFormState(previous => {
if (!previous.open || previous.assignment?.assignment_id !== assignment.assignment_id) return previous;
return {
...previous,
imageStatus: status,
statusLoading: false,
images: String(status?.database_image_count ?? assignment.resulting_images ?? 0),
date: toIsoDate(status?.effective_at) || previous.date,
error: ''
};
});
} catch (error) {
if (error && error.status === 401) {
onSessionExpired();
return;
}
setFormState(previous => ({
...previous,
statusLoading: false,
error: error && error.message ? error.message : 'No fue posible consultar las imágenes actuales.'
}));
}
};
const closeFormModal = () => {
const assignmentId = formState.assignment?.assignment_id;
if (assignmentId && operationById[assignmentId]) return;
setUploadProgress({ active: false, uploaded: 0, total: 0, batch: 0, totalBatches: 0, percent: 0, message: '' });
setFormState({
open: false,
mode: 'complete',
assignment: null,
images: '',
files: [],
imageOperation: 'append',
imageStatus: null,
statusLoading: false,
date: new Date().toISOString().slice(0, 10),
error: ''
});
};
const uploadImagesInBatches = async (assignmentId, operation, selectedFiles) => {
const total = selectedFiles.length;
const totalBatches = Math.ceil(total / DIGITALIZATION_UPLOAD_BATCH_SIZE);
const uploadId = `${assignmentId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
let finalData = null;
setUploadProgress({
active: true,
uploaded: 0,
total,
batch: 0,
totalBatches,
percent: 0,
message: `Preparando ${total} imágenes…`
});
for (let index = 0; index < totalBatches; index += 1) {
const start = index * DIGITALIZATION_UPLOAD_BATCH_SIZE;
const batchFiles = selectedFiles.slice(start, start + DIGITALIZATION_UPLOAD_BATCH_SIZE);
const currentBatch = index + 1;
setUploadProgress(previous => ({
...previous,
active: true,
batch: currentBatch,
totalBatches,
message: `Subiendo lote ${currentBatch} de ${totalBatches}…`
}));
const uploadBody = new FormData();
uploadBody.append('assignment_id', String(assignmentId));
uploadBody.append('mode', operation);
uploadBody.append('upload_id', uploadId);
uploadBody.append('batch_index', String(currentBatch));
uploadBody.append('total_batches', String(totalBatches));
uploadBody.append('total_files', String(total));
batchFiles.forEach(file => uploadBody.append('images[]', file, file.name));
const uploadResponse = await window.apiRequest('digitalization/upload-images.php', {
method: 'POST',
body: uploadBody,
timeout: 180000
});
finalData = uploadResponse && uploadResponse.data ? uploadResponse.data : {};
const uploaded = Math.min(total, start + batchFiles.length);
const percent = total > 0 ? Math.round((uploaded / total) * 100) : 100;
setUploadProgress({
active: currentBatch < totalBatches,
uploaded,
total,
batch: currentBatch,
totalBatches,
percent,
message: currentBatch < totalBatches
? `${uploaded} de ${total} imágenes cargadas`
: `Carga completa: ${uploaded} de ${total} imágenes`
});
}
return finalData || {};
};
const submitServerForm = async (event) => {
event.preventDefault();
const assignment = formState.assignment;
const assignmentId = assignment?.assignment_id;
let images = parseInt(formState.images, 10);
if (!assignmentId) {
setFormState(previous => ({ ...previous, error: 'La asignación ya no está disponible.' }));
return;
}
if (!formState.date) {
setFormState(previous => ({ ...previous, error: 'La fecha de digitalización es obligatoria.' }));
return;
}
const isComplete = formState.mode === 'complete';
const operation = isComplete ? 'replace' : formState.imageOperation;
const requiresFiles = isComplete || ['append', 'replace'].includes(operation);
if (requiresFiles && formState.files.length === 0) {
setFormState(previous => ({ ...previous, error: 'Selecciona las imágenes JPG que se van a subir.' }));
return;
}
if (operation === 'database_only') {
if (isNaN(images) || images < 0) {
setFormState(previous => ({ ...previous, error: 'Ingresa una cantidad válida para la base de datos.' }));
return;
}
} else if (isComplete || operation === 'replace') {
images = formState.files.length;
} else if (operation === 'append') {
images = Number(formState.imageStatus?.filesystem_image_count || 0) + formState.files.length;
}
const action = formState.mode === 'edit' ? 'editing-completion' : 'completing';
setOperation(assignmentId, action);
setFeedback(null);
setFormState(previous => ({ ...previous, error: '' }));
try {
let uploadData = null;
if (operation !== 'database_only') {
if (operation === 'replace' && formState.mode === 'edit') {
const confirmed = window.confirm(
`Se reemplazarán las ${formState.imageStatus?.filesystem_image_count || 0} imágenes actuales. `
+ 'Los archivos existentes se moverán a Backup antes de continuar. ¿Deseas continuar?'
);
if (!confirmed) {
clearOperation(assignmentId);
return;
}
}
uploadData = await uploadImagesInBatches(assignmentId, operation, formState.files);
images = Number(uploadData.final_count ?? images);
}
if (formState.mode === 'edit') {
await window.apiRequest('digitalization/update-completion.php', {
method: 'POST',
body: {
assignment_id: assignmentId,
resulting_images: images,
effective_at: formState.date
}
});
const successMessage = operation === 'database_only'
? 'La cantidad registrada en la base de datos se actualizó sin modificar archivos.'
: `La digitalización se actualizó correctamente. La carpeta contiene ${images} imágenes.`;
setFeedback({ tone: 'success', message: successMessage });
setSuccessModal({
open: true,
title: 'Digitalización actualizada',
message: successMessage,
path: uploadData?.relative_path || formState.imageStatus?.relative_path || '',
count: images
});
} else {
await window.assignmentsService.completeAssignment(assignmentId, {
resulting_images: images,
effective_at: formState.date,
upload_relative_path: uploadData?.relative_path || null,
uploaded_files: Array.isArray(uploadData?.files) ? uploadData.files : []
});
const successMessage = `La digitalización se registró correctamente. Se guardaron ${images} imágenes.`;
setFeedback({ tone: 'success', message: successMessage });
setSuccessModal({
open: true,
title: 'Carga completada',
message: successMessage,
path: uploadData?.relative_path || 'INPI 2026/DIGITALIZACION',
count: images
});
}
setFormState({
open: false,
mode: 'complete',
assignment: null,
images: '',
files: [],
imageOperation: 'append',
imageStatus: null,
statusLoading: false,
date: new Date().toISOString().slice(0, 10),
error: ''
});
setUploadProgress(previous => ({ ...previous, active: false, percent: 100, message: 'Carga completada.' }));
refreshData();
} catch (error) {
setUploadProgress(previous => ({
...previous,
active: false,
message: previous.total > 0
? `La carga se detuvo después de ${previous.uploaded} de ${previous.total} imágenes.`
: previous.message
}));
if (error && error.status === 401) {
onSessionExpired();
return;
}
setFormState(previous => ({
...previous,
error: error && error.message ? error.message : (formState.mode === 'edit'
? 'No fue posible editar la digitalización.'
: 'No fue posible finalizar la digitalización.')
}));
} finally {
clearOperation(assignmentId);
}
};
const queueItems = state.queue;
const queuePagination = state.queuePagination || { page: 1, per_page: queuePerPage, total: 0, total_pages: 0 };
const recentItems = state.recent;
const isSavingForm = formState.assignment ? Boolean(operationById[formState.assignment.assignment_id]) : false;
if (state.status === 'error') {
return (
);
}
return (
{feedback && (
{feedback.message}
)}
Mi cola de digitalización
Obras no digitalizadas disponibles y trabajos tomados por ti · Los libros tomados por otros usuarios no aparecen
| Núm. |
Serie / Título |
Autor |
Págs. |
Estatus |
Acción |
{state.status === 'loading' && (
|
)}
{state.status === 'loaded' && queueItems.length === 0 && (
|
)}
{queueItems.map(item => {
const assignmentStatus = item.status;
const isAvailable = item.queue_type === 'available';
const localStatusKey = DIGITALIZATION_ASSIGNMENT_STATUS[assignmentStatus] || 'nuevo';
const operationKey = isAvailable ? `book-${item.book?.id}` : item.assignment_id;
const operation = operationById[operationKey];
return (
| #{item.book?.inventory_number || item.book?.id || item.assignment_id} |
{item.book?.series_collection || 'Sin serie'}
{item.book?.title || 'Libro sin título'}
|
{item.book?.author || 'Sin autor'} |
{fmtNum(item.book?.total_pages || 0)} |
{isAvailable ? (
Disponible
) : }
|
{isAvailable ? (
) : assignmentStatus === 'pending' ? (
) : (
)}
|
);
})}
{queuePagination.total === 0
? 'Sin resultados'
: `Mostrando ${(queuePagination.page - 1) * queuePagination.per_page + 1}–${Math.min(queuePagination.page * queuePagination.per_page, queuePagination.total)} de ${queuePagination.total} obras`}
Página {queuePagination.total_pages ? queuePagination.page : 0} de {queuePagination.total_pages || 0}
Mis libros digitalizados
Últimos 6 libros procesados
| Núm. |
Título |
Fecha |
Imágenes |
Acumulado |
Estatus actual |
Acciones |
{state.status === 'loading' && (
|
)}
{state.status === 'loaded' && recentItems.length === 0 && (
|
)}
{recentItems.map(item => {
const currentStatusKey = mapCurrentBookStatus(item.book?.current_stage, item.book?.status);
const operation = operationById[item.assignment_id];
return (
| #{item.book?.inventory_number || item.book?.id || item.assignment_id} |
{item.book?.title || 'Libro sin título'} |
{formatShortDate(item.completed_at)} |
{fmtNum(item.resulting_images || 0)} |
{fmtNum(item.cumulative_images || 0)} |
|
|
);
})}
>}
>
{formState.assignment && (
)}
setDetailItem(null)}
onSessionExpired={onSessionExpired}
/>
setSuccessModal({ open: false, title: '', message: '', path: '', count: 0 })}
title={successModal.title || 'Operación completada'}
maxWidth={520}
footer={
}
>
{successModal.message}
{successModal.path && (
Carpeta: {successModal.path}
Primer archivo: {successModal.path}\1.jpg
)}
);
};
const Digitalizacion = (props) => {
const serverMode = window.APP_CONFIG.AUTH.MODE === 'server';
if (serverMode) {
return ;
}
return ;
};
Object.assign(window, { Digitalizacion });