// ============================================================
// Módulo Control de Calidad
// ============================================================
const ControlCalidadLocal = ({ libros, currentUser, onUpdate, onSelectBook }) => {
const [revisando, setRevisando] = useState(null);
const [decision, setDecision] = useState(null); // 'aceptado' | 'rechazado'
const [observaciones, setObservaciones] = useState('');
// Cola: libros editados esperando CC
const cola = libros.filter(l => l.estatus === 'editado')
.sort((a, b) => a.fechaEdicion.localeCompare(b.fechaEdicion));
const revisadosHoy = libros.filter(l =>
l.ccPor === currentUser.id &&
l.fechaControlCalidad === '2026-08-02'
);
const aceptadosHoy = revisadosHoy.filter(l => ['aceptado','en_conversion','finalizado'].includes(l.estatus)).length;
const rechazadosHoy = revisadosHoy.filter(l => l.estatus === 'rechazado').length;
const totalAceptados = libros.filter(l => ['aceptado','en_conversion','finalizado'].includes(l.estatus)).length;
const totalRechazados = libros.filter(l => l.estatus === 'rechazado').length;
const tasaAcept = totalAceptados + totalRechazados > 0 ? (totalAceptados / (totalAceptados + totalRechazados)) * 100 : 0;
const abrirRevision = (libro) => {
setRevisando(libro);
setDecision(null);
setObservaciones('');
};
const guardarDecision = () => {
if (!revisando || !decision) return;
if (decision === 'rechazado' && !observaciones.trim()) return;
onUpdate(revisando.cons, {
estatus: decision,
fechaControlCalidad: '2026-08-02',
ccPor: currentUser.id,
observaciones: decision === 'rechazado' ? observaciones : null,
});
setRevisando(null);
};
return (
Cola de control de calidad (FIFO)
Libros editados esperando revisión · ordenados por fecha de edición
#
Núm.
Título
Editor
Fecha edición
Imágs.
Acción
{cola.length === 0 && (
)}
{cola.map((l, i) => {
const editor = getUser(l.editadoPor);
return (
{i + 1}
#{l.cons}
{l.titulo}
{l.autor}
{editor && {editor.nombre.split(' ')[0]} }
{fmtDate(l.fechaEdicion)}
{fmtNum(l.imagenesEditadas)}
abrirRevision(l)}>Revisar
);
})}
{/* Historial reciente */}
Últimos aceptados
Pasan a conversión
{libros.filter(l => ['aceptado','en_conversion','finalizado'].includes(l.estatus))
.slice(-5).reverse().map(l => (
onSelectBook(l)} style={{ cursor: 'pointer' }}>
#{l.cons}
{l.titulo}
{fmtDate(l.fechaControlCalidad)}
))}
Rechazados con observaciones
Requieren reproceso
{libros.filter(l => l.estatus === 'rechazado').length === 0 && (
)}
{libros.filter(l => l.estatus === 'rechazado').slice(0, 5).map(l => (
onSelectBook(l)}>
#{l.cons}
{fmtDate(l.fechaControlCalidad)}
{l.titulo}
Obs: {l.observaciones}
))}
{/* Modal de revisión */}
setRevisando(null)}
title="Revisión de Control de Calidad"
maxWidth={560}
footer={<>
setRevisando(null)}>Cancelar
Guardar decisión
>}
>
{revisando && (
#{revisando.cons} · {revisando.serie}
{revisando.titulo}
{revisando.autor}
Digitalizado
{fmtDate(revisando.fechaDigitalizacion)} · {getUser(revisando.digitalizadoPor)?.nombre}
Editado
{fmtDate(revisando.fechaEdicion)} · {getUser(revisando.editadoPor)?.nombre}
Imágenes originales
{fmtNum(revisando.imagenesResultantes)}
Imágenes editadas
{fmtNum(revisando.imagenesEditadas)}
Decisión
setDecision('aceptado')}
>
Aceptar
setDecision('rechazado')}
>
Rechazar
{decision === 'rechazado' && (
Observaciones (obligatorio)
)}
{decision === 'aceptado' && (
✓ El libro pasará automáticamente a la etapa de Conversión con todos los formatos pendientes.
)}
)}
);
};
const qcFormatDate = (value) => {
if (!value) return '—';
const parsed = new Date(String(value).replace(' ', 'T'));
return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleDateString('es-MX');
};
const qcCorrectionStatusLabel = (status) => ({
pending_digitizer: 'En proceso',
pending_editor: 'En proceso',
digitizer_in_progress: 'En proceso',
editor_in_progress: 'En proceso',
digitizer_submitted: 'Corregido',
editor_submitted: 'Corregido',
quality_review: 'Corregido',
accepted: 'Libros corregidos'
}[status] || status || 'En proceso');
const qcCorrectionCanReview = (status) => ['digitizer_submitted','editor_submitted','quality_review'].includes(status);
const QualityControlServer = ({ currentUser, onSessionExpired }) => {
const [data, setData] = useState({ status: 'loading', queue: [], active: [], corrections: [], accepted: [], today: {}, totals: {}, error: '' });
const [reviewId, setReviewId] = useState(null);
const [detail, setDetail] = useState(null);
const [tab, setTab] = useState('editing');
const [selected, setSelected] = useState(null);
const [issue, setIssue] = useState('');
const [observation, setObservation] = useState('');
const [responsibleStage, setResponsibleStage] = useState('editing');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState(null);
const [returnModal, setReturnModal] = useState({ open: false, stage: 'editing', scope: 'selected_images', reason: '' });
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const dragOrigin = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
const viewerRef = useRef(null);
const reload = async () => {
setData(previous => ({ ...previous, status: 'loading', error: '' }));
try {
const response = await apiRequest('quality-control/dashboard.php');
setData({
status: 'loaded',
queue: response.data.queue || [],
active: response.data.active || [],
corrections: response.data.corrections || [],
accepted: response.data.accepted || [],
today: response.data.today || {},
totals: response.data.totals || {},
error: ''
});
} catch (error) {
if (error.status === 401) return onSessionExpired();
setData(previous => ({ ...previous, status: 'error', error: error.message }));
}
};
useEffect(() => { reload(); }, []);
const setSelectedImage = (image) => {
setSelected(image || null);
setIssue(image?.issue_type || '');
setObservation(image?.observation || '');
setResponsibleStage(image?.responsible_stage || 'editing');
setZoom(1);
setPan({ x: 0, y: 0 });
setDragging(false);
};
const clampZoom = value => Math.min(5, Math.max(0.5, Number(value) || 1));
const changeZoom = nextValue => {
const next = clampZoom(nextValue);
setZoom(next);
if (next <= 1) setPan({ x: 0, y: 0 });
};
const handleViewerWheel = event => {
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === 'function') {
event.stopImmediatePropagation();
}
const direction = event.deltaY < 0 ? 0.2 : -0.2;
setZoom(currentZoom => {
const nextZoom = clampZoom(currentZoom + direction);
if (nextZoom <= 1) {
setPan({ x: 0, y: 0 });
}
return nextZoom;
});
};
useEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return undefined;
const blockPageScroll = event => handleViewerWheel(event);
viewer.addEventListener('wheel', blockPageScroll, { passive: false });
return () => {
viewer.removeEventListener('wheel', blockPageScroll);
};
}, [reviewId, tab]);
const beginPan = event => {
if (zoom <= 1) return;
event.preventDefault();
event.stopPropagation();
setDragging(true);
dragOrigin.current = {
x: event.clientX,
y: event.clientY,
panX: pan.x,
panY: pan.y
};
};
const movePan = event => {
if (!dragging) return;
event.preventDefault();
event.stopPropagation();
setPan({
x: dragOrigin.current.panX + event.clientX - dragOrigin.current.x,
y: dragOrigin.current.panY + event.clientY - dragOrigin.current.y
});
};
const endPan = () => setDragging(false);
const openReview = async (id) => {
setReviewId(id);
setDetail(null);
setTab('editing');
try {
const response = await apiRequest(`quality-control/detail.php?review_id=${id}`);
setDetail(response.data);
const correctionStatus = response.data?.active_correction?.status;
if (correctionStatus === 'digitizer_submitted') {
setTab('digitization');
setSelectedImage((response.data.digitization_images || [])[0] || null);
} else {
setTab('editing');
setSelectedImage((response.data.editing_images || [])[0] || null);
}
} catch (error) {
if (error.status === 401) return onSessionExpired();
setMessage({ tone: 'error', text: error.message });
setReviewId(null);
}
};
const claim = async (bookId) => {
setSaving(true);
try {
const response = await apiRequest('quality-control/claim.php', { method: 'POST', body: { book_id: bookId } });
await reload();
await openReview(response.data.review_id);
} catch (error) {
setMessage({ tone: 'error', text: error.message });
} finally {
setSaving(false);
}
};
const images = detail
? (tab === 'digitization' ? detail.digitization_images : detail.editing_images)
: [];
const selectedIndex = selected ? images.findIndex(image => image.file_name === selected.file_name) : -1;
const imageBaseUrl = APP_CONFIG.API_BASE_URL.replace(/\/$/, '');
const imgUrl = selected && reviewId && tab !== 'compare'
? `${imageBaseUrl}/quality-control/image.php?review_id=${reviewId}&source=${tab}&file=${encodeURIComponent(selected.file_name)}`
: '';
const compareEditingUrl = selected && reviewId
? `${imageBaseUrl}/quality-control/image.php?review_id=${reviewId}&source=editing&file=${encodeURIComponent(selected.file_name)}`
: '';
const compareOriginalName = selected && detail
? ((detail.digitization_images || []).find(image => image.file_name === selected.file_name)?.file_name
|| (detail.digitization_images || [])[selectedIndex]?.file_name
|| selected.file_name)
: '';
const compareDigitizationUrl = compareOriginalName && reviewId
? `${imageBaseUrl}/quality-control/image.php?review_id=${reviewId}&source=digitization&file=${encodeURIComponent(compareOriginalName)}`
: '';
const nextPendingImage = (updatedImages, currentIndex) => {
for (let index = currentIndex + 1; index < updatedImages.length; index += 1) {
if ((updatedImages[index].status || 'pending') === 'pending') return updatedImages[index];
}
for (let index = 0; index < updatedImages.length; index += 1) {
if ((updatedImages[index].status || 'pending') === 'pending') return updatedImages[index];
}
return updatedImages[Math.min(currentIndex + 1, updatedImages.length - 1)] || null;
};
const saveImage = async (status) => {
if (!selected || tab === 'digitization') return;
if (status === 'observed' && (!issue || !observation.trim())) {
setMessage({ tone: 'error', text: 'Selecciona el tipo de incidencia y escribe la observación.' });
return;
}
setSaving(true);
try {
const saveResponse = await apiRequest('quality-control/save-image.php', {
method: 'POST',
body: {
image_id: selected.id,
status,
issue_type: status === 'observed' ? issue : null,
observation: status === 'observed' ? observation.trim() : null,
responsible_stage: status === 'observed' ? responsibleStage : null
}
});
if (saveResponse?.data?.correction_created) {
setReviewId(null);
setDetail(null);
await reload();
setMessage({
tone: 'success',
text: 'La nueva observación se guardó y el libro regresó a En proceso de corrección.'
});
return;
}
const response = await apiRequest(`quality-control/detail.php?review_id=${reviewId}`);
const refreshed = response.data;
setDetail(refreshed);
const refreshedImages = refreshed.editing_images || [];
const currentIndex = refreshedImages.findIndex(image => image.file_name === selected.file_name);
setSelectedImage(nextPendingImage(refreshedImages, currentIndex));
setMessage({ tone: 'success', text: saveResponse?.data?.changed === false
? 'No se detectaron cambios; el libro conserva su estado actual.'
: (status === 'correct' ? 'Imagen correcta. Se abrió la siguiente pendiente.' : 'Observación guardada. Se abrió la siguiente pendiente.') });
} catch (error) {
setMessage({ tone: 'error', text: error.message });
} finally {
setSaving(false);
}
};
const decide = async () => {
setSaving(true);
try {
await apiRequest('quality-control/decision.php', { method: 'POST', body: { review_id: reviewId, decision: 'approved', general_observations: null } });
setReviewId(null);
setDetail(null);
await reload();
setMessage({ tone: 'success', text: 'Libro aprobado y enviado a Conversión.' });
} catch (error) {
setMessage({ tone: 'error', text: error.message });
} finally {
setSaving(false);
}
};
const dispatchCorrections = async () => {
if (!detail || Number(detail.review.reviewed_images) < Number(detail.review.total_images)) {
setMessage({ tone: 'error', text: 'Debes revisar todas las imágenes antes de enviar correcciones.' });
return;
}
if (Number(detail.review.observed_images) < 1) {
setMessage({ tone: 'error', text: 'No existen imágenes con observación.' });
return;
}
setSaving(true);
try {
const response = await apiRequest('quality-control/dispatch-corrections.php', {
method: 'POST',
body: { review_id: reviewId }
});
setReviewId(null);
setDetail(null);
await reload();
setMessage({
tone: 'success',
text: `Las observaciones se enviaron al Buzón de correcciones. Solicitud #${response.data.correction_id}.`
});
} catch (error) {
setMessage({ tone: 'error', text: error.message });
} finally {
setSaving(false);
}
};
const validateActiveCorrection = async (decision) => {
const correction = detail?.active_correction;
if (!correction?.id) return;
const reason = decision === 'rejected'
? window.prompt('Motivo del rechazo:', '')
: '';
if (decision === 'rejected' && !reason) return;
setSaving(true);
setMessage(null);
try {
const response = await apiRequest('corrections/validate.php', {
method: 'POST',
body: {
correction_id: Number(correction.id),
decision,
reason
}
});
setReviewId(null);
setDetail(null);
await reload();
const nextStatus = response?.data?.status;
setMessage({
tone: 'success',
text: nextStatus === 'pending_editor'
? 'La corrección de Digitalización fue validada. Ahora se envió una nueva solicitud al editor para actualizar las imágenes editadas.'
: decision === 'approved'
? 'La corrección fue validada correctamente.'
: 'La corrección fue rechazada y regresó al responsable.'
});
} catch (error) {
if (error?.status === 401) {
onSessionExpired();
return;
}
setMessage({ tone: 'error', text: error?.message || 'No fue posible validar la corrección.' });
} finally {
setSaving(false);
}
};
const submitReturn = async () => {
if (!returnModal.reason.trim()) return;
setSaving(true);
try {
const response = await apiRequest('quality-control/return-book.php', {
method: 'POST',
body: {
review_id: reviewId,
responsible_stage: returnModal.stage,
scope: returnModal.scope,
reason: returnModal.reason.trim()
}
});
setReturnModal({ open: false, stage: 'editing', scope: 'selected_images', reason: '' });
setReviewId(null);
setDetail(null);
await reload();
setMessage({ tone: 'success', text: `Libro regresado. Se creó la corrección #${response.data.correction_id}.` });
} catch (error) {
setMessage({ tone: 'error', text: error.message });
} finally {
setSaving(false);
}
};
if (reviewId) {
return
{ setReviewId(null); setDetail(null); reload(); }}>← Regresar a Control de calidad
{detail?.review?.status !== 'approved' && (
setReturnModal(previous => ({ ...previous, open: true }))}>Regresar todo el libro
)}
{detail && detail.review.status !== 'approved' && Number(detail.review.reviewed_images) >= Number(detail.review.total_images) && Number(detail.review.observed_images) > 0 && (
Enviar observaciones a corrección
)}
{detail?.review?.status === 'approved' ? (
Libro aceptado
) : (
0} onClick={decide}>Aprobar y enviar a Conversión
)}
{message &&
{message.text}
}
{!detail ?
: <>
{detail.review.title}
#{detail.review.inventory_number} · {detail.review.series_collection}
Digitalizado por
{detail.actors.digitization?.name || '—'}
Editado por
{detail.actors.editing?.name || '—'}
Imágenes digitalizadas
{detail.digitization_images.length}
Imágenes editadas
{detail.editing_images.length}
{ setTab('editing'); setSelectedImage(detail.editing_images[0] || null); }}>Edición ({detail.editing_images.length})
{ setTab('digitization'); setSelectedImage(detail.digitization_images[0] || null); }}>Digitalización ({detail.digitization_images.length})
{ setTab('compare'); setSelectedImage(detail.editing_images[0] || null); }}>Comparar
{images.map((image, index) => setSelectedImage(image)} className={selected?.file_name === image.file_name ? 'quality-image-row active' : 'quality-image-row'}>{index + 1}. {image.file_name} {image.status === 'correct' ? 'Correcta' : image.status === 'observed' ? 'Observada' : image.status === 'corrected' ? 'Corregida' : 'Pendiente'} )}
changeZoom(zoom-0.2)} disabled={zoom<=0.5}>−
{Math.round(zoom*100)}%
changeZoom(zoom+0.2)} disabled={zoom>=5}>+
{setZoom(1);setPan({x:0,y:0});}}>Ajustar
changeZoom(2)}>200%
Rueda para zoom · Arrastra al ampliar
1?(dragging?'grabbing':'grab'):'default',
display:'flex',
alignItems:'center',
justifyContent:'center',
background:'var(--surface-2)'
}}
>
{tab === 'compare' ? (
selected ?
Digitalización
Edición
:
Selecciona una imagen
) : (imgUrl ?
:
Selecciona una imagen
)}
Imagen {selectedIndex + 1} de {images.length}
Resultado de la imagen
{tab === 'digitization' ? (
detail?.active_correction && detail.active_correction.status === 'digitizer_submitted' ?
El digitalizador sustituyó la imagen en DIGITALIZACION . Revisa esta pestaña antes de continuar.
validateActiveCorrection('approved')}>
Validar Digitalización y enviar al editor
validateActiveCorrection('rejected')}>
Rechazar corrección de Digitalización
:
La imagen original se muestra como referencia. Cuando exista una corrección enviada por Digitalización, aquí aparecerán las opciones para validarla antes de notificar al editor.
) : <>
saveImage('correct')}>Marcar como correcta y continuar setIssue(event.target.value)}>Tipo de incidencia {['Imagen borrosa','Recorte incorrecto','Página incompleta','Orientación incorrecta','Mancha o sombra','Orden incorrecto','Imagen duplicada','Imagen faltante','Color incorrecto','Problema de digitalización','Problema de edición','Otra'].map(value => {value} )} setResponsibleStage(event.target.value)} style={{ marginTop: 10 }}>Responsable: Edición Responsable: Digitalización
Avance: {detail.review.reviewed_images}/{detail.review.total_images} · Correctas: {detail.review.correct_images} · Con observación: {detail.review.observed_images}{Number(detail.review.reviewed_images) >= Number(detail.review.total_images) && Number(detail.review.observed_images) > 0 &&
La revisión terminó con observaciones. Presiona Enviar observaciones a corrección para que aparezcan en el buzón del responsable.
}
>}
setReturnModal(previous => ({ ...previous, open: false }))} title="Regresar todo el libro" maxWidth={620} footer={<> setReturnModal(previous => ({ ...previous, open: false }))}>Cancelar Respaldar y regresar el libro >}>
Antes de limpiar archivos se crea un respaldo con fecha y hora. La operación queda registrada para evitar duplicaciones.
Responsable de la corrección setReturnModal(previous => ({ ...previous, stage: event.target.value }))}>Edición Digitalización Digitalización y después Edición
Alcance setReturnModal(previous => ({ ...previous, scope: event.target.value }))}>Solo imágenes observadas Todo el libro
Motivo
;
}
const approved = Number(data.totals.approved || 0), rejected = Number(data.totals.rejected || 0), rate = approved + rejected ? Math.round(approved * 100 / (approved + rejected)) : 0;
return
{message &&
{message.text}
}
Cola de control de calidad (FIFO)
Libros editados con imágenes disponibles
# Núm. Título Editor Fecha edición Imágs. Acción {data.queue.map((book, index) => {index + 1} #{book.inventory_number} {book.title} {book.author}
{book.editor_name} {qcFormatDate(book.edited_at)} {Number(book.edited_images || 0)} claim(book.id)}>Revisar )}{!data.queue.length && }
En proceso de corrección
Libros enviados al responsable y correcciones pendientes de validar
Libro Serie / Título Responsable Imágenes Estado Actualización Acción {data.corrections.map(item => #{item.inventory_number} {item.title} {item.series_collection}
{item.assigned_user_name || 'Sin asignar'} {Number(item.image_count || 0)} {qcCorrectionStatusLabel(item.status)}{qcFormatDate(item.updated_at)} {qcCorrectionCanReview(item.status) ? openReview(item.quality_review_id)}>Revisar corrección : En proceso } )}{!data.corrections.length && }
Libros aceptados
Revisiones aprobadas; abrir no cambia el estado mientras no registres una nueva observación
Libro Serie / Título Revisó Imágenes Fecha de aceptación Estado Acción {data.accepted.map(item => #{item.inventory_number} {item.title} {item.series_collection}
{item.reviewer_name} {Number(item.total_images || 0)} {qcFormatDate(item.completed_at)} Aceptado openReview(item.review_id)}>Revisar )}{!data.accepted.length && }
{!!data.active.length &&
{data.active.map(review => openReview(review.review_id)}>{review.title} · {review.reviewed_images}/{review.total_images} )}
}
;
};
const ControlCalidad = (props) => window.APP_CONFIG.AUTH.MODE === 'server'
?
: ;
Object.assign(window, { ControlCalidad });