// ============================================================ // Panel Supervisor - Asignación FIFO // ============================================================ const SupervisorLocal = ({ libros, onUpdate, onSelectBook }) => { const [asignando, setAsignando] = useState(null); const [editorElegido, setEditorElegido] = useState(null); // Cola FIFO de digitalizados const digitalizados = libros.filter(l => l.estatus === 'digitalizado') .sort((a, b) => a.fechaDigitalizacion.localeCompare(b.fechaDigitalizacion)); const editores = APP_DATA.USUARIOS.filter(u => u.roles.includes('edicion')); // Cargas de trabajo por editor const cargas = {}; editores.forEach(e => { cargas[e.id] = { user: e, enCurso: libros.filter(l => l.estatus === 'en_edicion' && l.editadoPor === e.id).length, totalEditados: libros.filter(l => l.editadoPor === e.id && ['editado','en_cc','aceptado','rechazado','en_conversion','finalizado'].includes(l.estatus) ).length, }; }); const editorMenosCargado = () => { return Object.values(cargas).sort((a, b) => a.enCurso - b.enCurso)[0]?.user; }; const asignarSiguiente = () => { const primero = digitalizados[0]; const editor = editorMenosCargado(); if (primero && editor) { onUpdate(primero.cons, { estatus: 'en_edicion', editadoPor: editor.id }); } }; const abrirAsignacion = (libro) => { setAsignando(libro); setEditorElegido(editorMenosCargado()?.id || null); }; const confirmarAsignacion = () => { if (!asignando || !editorElegido) return; onUpdate(asignando.cons, { estatus: 'en_edicion', editadoPor: editorElegido }); setAsignando(null); }; // KPIs globales const totalPendientes = libros.filter(l => ['nuevo','en_digitalizacion','digitalizado','en_edicion','editado','en_cc','en_conversion'].includes(l.estatus)).length; const finalizados = libros.filter(l => l.estatus === 'finalizado').length; const rechazados = libros.filter(l => l.estatus === 'rechazado').length; return (
{/* Asignación FIFO */}
Cola FIFO · Libros digitalizados esperando asignación
Primeras entradas, primeras salidas · asigna en orden para respetar el flujo
{digitalizados.length === 0 && }
{digitalizados.map((l, i) => { const dias = daysBetween(l.fechaDigitalizacion, '2026-08-02'); const digitalizador = getUser(l.digitalizadoPor); return (
5 ? 'urgent' : ''}`}>{i + 1}
#{l.cons} · {l.serie}
{l.titulo}
{fmtNum(l.imagenesResultantes)} imág · Digitalizó {digitalizador?.nombre.split(' ')[0]} · Hace {dias} día{dias === 1 ? '' : 's'}
{dias > 5 && Urgente} {dias >= 3 && dias <= 5 && Atención}
{fmtDate(l.fechaDigitalizacion)}
); })}
{/* Cargas por editor */}
Cargas de trabajo por editor
Balance actual · el menor primero
{Object.values(cargas).sort((a, b) => a.enCurso - b.enCurso).map(c => (
{c.user.nombre}
{c.user.roles.includes('supervisor') ? 'Supervisor·Editor' : 'Editor'}
En curso
{c.enCurso}
Total
{c.totalEditados}
))}
{/* Kanban global */}
Vista global del pipeline
Estado de todos los libros en cada etapa
{[ { title: 'Digitalización', keys: ['nuevo','en_digitalizacion','digitalizado'] }, { title: 'Edición', keys: ['en_edicion','editado'] }, { title: 'Control Calidad', keys: ['en_cc','aceptado','rechazado'] }, { title: 'Conversión / Final', keys: ['en_conversion','finalizado'] }, ].map(col => { const items = libros.filter(l => col.keys.includes(l.estatus)); return (
{col.title} {items.length}
{items.slice(0, 8).map(l => (
onSelectBook(l)}>
#{l.cons}
{l.titulo}
))} {items.length > 8 && (
+ {items.length - 8} más
)}
); })}
{/* Modal asignación */} setAsignando(null)} title="Asignar libro a editor" maxWidth={480} footer={<> } > {asignando && (
#{asignando.cons} · {asignando.serie}
{asignando.titulo}
{fmtNum(asignando.imagenesResultantes)} imágenes
Selecciona editor
{editores.map(e => { const c = cargas[e.id]; return (
setEditorElegido(e.id)} style={{ padding: 10, border: `2px solid ${editorElegido === e.id ? 'var(--primary)' : 'var(--border)'}`, background: editorElegido === e.id ? 'var(--primary-soft)' : 'var(--surface)', borderRadius: 6, cursor: 'pointer', }} >
{e.nombre}
{c.enCurso} en curso · {c.totalEditados} total
{c.enCurso === Math.min(...Object.values(cargas).map(x => x.enCurso)) && ( Menos cargado )}
); })}
)}
); }; const SUPERVISOR_STAGES = Object.freeze({ catalog: 'Catálogo', digitization: 'Digitalización', editing: 'Edición', quality_control: 'Control de calidad', conversion: 'Conversión', publication: 'Publicación', published: 'Publicado' }); const SUPERVISOR_ASSIGNABLE_STAGES = Object.freeze( Object.keys(SUPERVISOR_STAGES).filter(stage => stage !== 'published') ); const SUPERVISOR_STATUS_LABELS = Object.freeze({ pending: 'Pendiente', in_progress: 'En proceso', completed: 'Completada', cancelled: 'Cancelada', reassigned: 'Reasignada' }); const SUPERVISOR_HISTORY_LABELS = Object.freeze({ assignment_created: 'Asignación creada', assignment_cancelled: 'Asignación cancelada', assignment_reassigned: 'Asignación reasignada', assignment_created_from_reassignment: 'Nueva asignación por reasignación' }); const SupervisorFifoOverviewServer = ({ refreshToken = 0, onChanged, onSessionExpired }) => { const [state, setState] = useState('loading'); const [books, setBooks] = useState([]); const [queue, setQueue] = useState([]); const [workload, setWorkload] = useState([]); const [editors, setEditors] = useState([]); const [error, setError] = useState(''); const [assigning, setAssigning] = useState(null); const [selectedEditorId, setSelectedEditorId] = useState(''); const [submitting, setSubmitting] = useState(false); const [notice, setNotice] = useState(null); const loadAllBooks = async (filters = {}, signal) => { const first = await window.booksService.listBooks({ ...filters, page: 1, per_page: 100, signal }); const items = [...first.items]; for (let page = 2; page <= first.pagination.total_pages; page += 1) { const result = await window.booksService.listBooks({ ...filters, page, per_page: 100, signal }); items.push(...result.items); } return items; }; const loadActiveEditingAssignments = async signal => { const statuses = ['pending', 'in_progress']; const all = []; for (const status of statuses) { const first = await window.assignmentsService.listAssignments({ stage: 'editing', status, page: 1, per_page: 100, signal }); all.push(...first.items); for (let page = 2; page <= first.pagination.total_pages; page += 1) { const result = await window.assignmentsService.listAssignments({ stage: 'editing', status, page, per_page: 100, signal }); all.push(...result.items); } } return all; }; const reload = React.useCallback(async () => { const controller = new AbortController(); setState('loading'); setError(''); try { const [allBooks, completedDigitization, activeEditing, workloadItems, activeUsers] = await Promise.all([ loadAllBooks({ sort: 'updated_at', direction: 'asc' }, controller.signal), loadAllBooks({ current_stage: 'digitization', status: 'completed', sort: 'updated_at', direction: 'asc' }, controller.signal), loadActiveEditingAssignments(controller.signal), window.assignmentsService.listWorkload({ stage: 'editing', signal: controller.signal }), window.usersService.listUsers({ status: 'active', signal: controller.signal }) ]); const activeBookIds = new Set(activeEditing.map(item => item.bookId)); const availableQueue = completedDigitization.filter(book => !activeBookIds.has(book.serverId)); const compatibleEditors = window.usersService.getCompatibleUsersForStage(activeUsers, 'editing'); setBooks(allBooks); setQueue(availableQueue); setWorkload(workloadItems); setEditors(compatibleEditors); setState('loaded'); } catch (requestError) { if (controller.signal.aborted) return; if (Number(requestError && requestError.status) === 401) { if (onSessionExpired) onSessionExpired(); return; } setState('error'); setError((requestError && requestError.message) || 'No fue posible cargar la cola FIFO.'); } return () => controller.abort(); }, [refreshToken]); useEffect(() => { reload(); }, [reload]); const workloadFor = userId => workload.find(item => item.user.id === userId) || { pending: 0, inProgress: 0, activeTotal: 0, completed: 0 }; const leastLoadedEditor = () => [...editors].sort((a, b) => { const aLoad = workloadFor(a.id).activeTotal; const bLoad = workloadFor(b.id).activeTotal; if (aLoad !== bLoad) return aLoad - bLoad; return a.name.localeCompare(b.name, 'es'); })[0] || null; const openAssignment = book => { const editor = leastLoadedEditor(); setAssigning(book); setSelectedEditorId(editor ? String(editor.id) : ''); setNotice(null); }; const assignBook = async (book, userId) => { if (!book || !userId || submitting) return; setSubmitting(true); setNotice(null); try { await window.assignmentsService.createAssignment({ book_id: book.serverId, user_id: Number(userId), stage: 'editing', observations: 'Asignación creada desde la cola FIFO.' }); setAssigning(null); setSelectedEditorId(''); setNotice({ tone: 'success', text: 'El libro se asignó correctamente a edición.' }); await reload(); if (onChanged) onChanged(); } catch (requestError) { if (Number(requestError && requestError.status) === 401) { if (onSessionExpired) onSessionExpired(); return; } const message = Number(requestError && requestError.status) === 409 ? 'El libro ya tiene una asignación activa de edición.' : ((requestError && requestError.message) || 'No fue posible asignar el libro.'); setNotice({ tone: 'danger', text: message }); await reload(); } finally { setSubmitting(false); } }; const assignNext = () => { const book = queue[0]; const editor = leastLoadedEditor(); if (book && editor) assignBook(book, editor.id); }; const totalInProcess = books.filter(book => ['pending', 'assigned', 'in_progress', 'correction_required'].includes(book.status)).length; const completedBooks = books.filter(book => book.currentStage === 'published' || (book.currentStage === 'publication' && book.status === 'completed')).length; const rejectedBooks = books.filter(book => book.status === 'rejected' || book.status === 'correction_required').length; const projectGoal = Number(window.APP_DATA && window.APP_DATA.PROYECTO && window.APP_DATA.PROYECTO.metaLibros) || books.length || 1; const pipelineColumns = [ { title: 'Digitalización', stages: ['catalog', 'digitization'] }, { title: 'Edición', stages: ['editing'] }, { title: 'Control de calidad', stages: ['quality_control'] }, { title: 'Conversión / Final', stages: ['conversion', 'publication', 'published'] } ]; const formatDate = value => { if (!value) return '—'; const parsed = new Date(String(value).replace(' ', 'T')); return Number.isNaN(parsed.getTime()) ? '—' : parsed.toLocaleDateString('es-MX'); }; const statusLabel = book => { const labels = window.bookCatalogs && window.bookCatalogs.statuses; return (labels && labels[book.status]) || book.status || 'Pendiente'; }; return (
{notice &&
{notice.text}
}
Cola FIFO · Libros digitalizados esperando asignación
Primero digitalizado, primero asignado · datos reales del servidor
{state === 'loading' && } {state === 'error' &&
{error}
} {state === 'loaded' && queue.length === 0 && } {state === 'loaded' &&
{queue.slice(0, 20).map((book, index) => (
{index + 1}
#{book.inventoryNumber || book.cons || book.serverId} · {book.serie || 'Sin serie'}
{book.title || book.titulo}
{book.author || 'Autor no registrado'}· {book.totalPages || 0} páginas· Terminó digitalización: {formatDate(book.updatedAt)}
{formatDate(book.updatedAt)}
))} {queue.length > 20 &&
+ {queue.length - 20} libros más en cola
}
}
Cargas de trabajo por editor
Asignaciones activas de edición · menor carga primero
{[...editors].sort((a, b) => workloadFor(a.id).activeTotal - workloadFor(b.id).activeTotal).map(editor => { const load = workloadFor(editor.id); return
{editor.name}
{editor.roles.includes('supervisor') ? 'Supervisor · Editor' : 'Editor'}
EN CURSO
{load.activeTotal}
COMPLETADAS
{load.completed}
; })}
Vista global del pipeline
Estado real de los libros en cada etapa
{pipelineColumns.map(column => { const items = books.filter(book => column.stages.includes(book.currentStage)); return
{column.title}{items.length}
{items.slice(0, 8).map(book =>
#{book.inventoryNumber || book.cons || book.serverId}
{book.title || book.titulo}
{statusLabel(book)}
)} {items.length > 8 &&
+ {items.length - 8} más
}
; })}
!submitting && setAssigning(null)} title="Asignar libro a editor" maxWidth={520} footer={<>}> {assigning &&
#{assigning.inventoryNumber || assigning.cons || assigning.serverId} · {assigning.serie || 'Sin serie'}
{assigning.title}
{assigning.author || 'Autor no registrado'} · {assigning.totalPages || 0} páginas
Selecciona editor
{[...editors].sort((a, b) => workloadFor(a.id).activeTotal - workloadFor(b.id).activeTotal).map(editor => { const load = workloadFor(editor.id); const least = leastLoadedEditor(); return
setSelectedEditorId(String(editor.id))} style={{ padding: 10, border: `2px solid ${String(editor.id) === selectedEditorId ? 'var(--primary)' : 'var(--border)'}`, background: String(editor.id) === selectedEditorId ? 'var(--primary-soft)' : 'var(--surface)', borderRadius: 6, cursor: 'pointer' }}>
{editor.name}
{load.activeTotal} activas · {load.completed} completadas
{least && least.id === editor.id && Menos cargado}
; })}
}
); }; const SupervisorServer = ({ currentUser, onSessionExpired }) => { const canManage = window.authMapper.hasAnyRole(currentUser, ['administrator', 'supervisor']); const [filters, setFilters] = useState({ book_id: '', user_id: '', stage: '', status: '', assigned_from: '', assigned_to: '', page: 1, per_page: 10 }); const [assignments, setAssignments] = useState([]); const [pagination, setPagination] = useState({ page: 1, per_page: 10, total: 0, total_pages: 0 }); const [listState, setListState] = useState('loading'); const [listError, setListError] = useState(''); const [users, setUsers] = useState([]); const [workload, setWorkload] = useState([]); const [workloadState, setWorkloadState] = useState('loading'); const [refreshKey, setRefreshKey] = useState(0); const [notice, setNotice] = useState(null); const [filterBookSearch, setFilterBookSearch] = useState(''); const [filterBookResults, setFilterBookResults] = useState([]); const [filterBookLoading, setFilterBookLoading] = useState(false); const [detail, setDetail] = useState(null); const [detailState, setDetailState] = useState('idle'); const [history, setHistory] = useState([]); const [historyState, setHistoryState] = useState('idle'); const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState({ book: null, stage: '', user_id: '', observations: '' }); const [createBookSearch, setCreateBookSearch] = useState(''); const [createBookResults, setCreateBookResults] = useState([]); const [createBookLoading, setCreateBookLoading] = useState(false); const [createError, setCreateError] = useState(''); const [submittingCreate, setSubmittingCreate] = useState(false); const [cancelTarget, setCancelTarget] = useState(null); const [cancelReason, setCancelReason] = useState(''); const [cancelError, setCancelError] = useState(''); const [submittingCancel, setSubmittingCancel] = useState(false); const [reassignTarget, setReassignTarget] = useState(null); const [reassignForm, setReassignForm] = useState({ user_id: '', reason: '' }); const [reassignError, setReassignError] = useState(''); const [submittingReassign, setSubmittingReassign] = useState(false); const operationLocks = React.useRef({ create: false, cancel: false, reassign: false }); const formatDateTime = value => value ? new Date(String(value).replace(' ', 'T')).toLocaleString('es-MX') : '—'; const closeAllModals = () => { setCreateOpen(false); setCancelTarget(null); setReassignTarget(null); setDetail(null); }; const handleSessionExpired = () => { closeAllModals(); if (onSessionExpired) onSessionExpired(); }; const errorMessage = (error, operation = '') => { const status = Number(error && error.status) || 0; if (status === 401) { handleSessionExpired(); return ''; } if (status === 403) return 'No tienes permiso para realizar esta operación.'; if (status === 404) return 'La asignación, el libro o el usuario ya no está disponible.'; if (status === 409 && operation === 'create') { return 'Este libro ya tiene una asignación activa para la etapa seleccionada.'; } if (status === 409) return (error && error.message) || 'La asignación cambió y la operación ya no es válida.'; if (status === 422) return (error && error.message) || 'Verifica los datos capturados.'; if (status === 400) return 'No fue posible procesar la solicitud. Verifica los datos e inténtalo nuevamente.'; return 'No fue posible completar la operación. Verifica el estado del servidor e inténtalo nuevamente.'; }; const updateFilter = (name, value) => { setFilters(previous => ({ ...previous, [name]: value, page: name === 'page' ? value : 1 })); }; useEffect(() => { if (!canManage) return undefined; const controller = new AbortController(); window.usersService.listUsers({ status: 'active', signal: controller.signal }) .then(setUsers) .catch(error => { if (error && error.status === 401) handleSessionExpired(); else setNotice({ tone: 'danger', text: errorMessage(error) }); }); return () => controller.abort(); }, [canManage]); useEffect(() => { if (!canManage) return undefined; const controller = new AbortController(); setListState('loading'); setListError(''); window.assignmentsService.listAssignments({ ...filters, signal: controller.signal }) .then(result => { setAssignments(result.items); setPagination(result.pagination); setListState(result.items.length ? 'loaded' : 'empty'); }) .catch(error => { if (controller.signal.aborted) return; if (error && error.status === 401) { handleSessionExpired(); return; } setAssignments([]); setListState(Number(error && error.status) === 0 ? 'unavailable' : 'error'); setListError(errorMessage(error)); }); return () => controller.abort(); }, [canManage, filters.book_id, filters.user_id, filters.stage, filters.status, filters.assigned_from, filters.assigned_to, filters.page, filters.per_page, refreshKey]); useEffect(() => { if (!canManage) return undefined; const controller = new AbortController(); setWorkloadState('loading'); window.assignmentsService.listWorkload({ stage: filters.stage, user_id: filters.user_id, signal: controller.signal }).then(items => { setWorkload(items); setWorkloadState('loaded'); }).catch(error => { if (controller.signal.aborted) return; if (error && error.status === 401) handleSessionExpired(); else setWorkloadState('error'); }); return () => controller.abort(); }, [canManage, filters.stage, filters.user_id, refreshKey]); useEffect(() => { if (!canManage || filterBookSearch.trim().length < 2) { setFilterBookResults([]); return undefined; } const controller = new AbortController(); const timer = window.setTimeout(() => { setFilterBookLoading(true); window.booksService.listBooks({ search: filterBookSearch, page: 1, per_page: 8, signal: controller.signal }) .then(result => setFilterBookResults(result.items)) .catch(error => { if (error && error.status === 401) handleSessionExpired(); else setFilterBookResults([]); }) .finally(() => setFilterBookLoading(false)); }, 250); return () => { window.clearTimeout(timer); controller.abort(); }; }, [canManage, filterBookSearch]); useEffect(() => { if (!createOpen || createBookSearch.trim().length < 2) { setCreateBookResults([]); return undefined; } const controller = new AbortController(); const timer = window.setTimeout(() => { setCreateBookLoading(true); window.booksService.listBooks({ search: createBookSearch, page: 1, per_page: 10, signal: controller.signal }) .then(result => setCreateBookResults(result.items)) .catch(error => { if (error && error.status === 401) handleSessionExpired(); else setCreateBookResults([]); }) .finally(() => setCreateBookLoading(false)); }, 250); return () => { window.clearTimeout(timer); controller.abort(); }; }, [createOpen, createBookSearch]); const loadDetail = async (assignmentId, knownBookId = null) => { setDetailState('loading'); setHistoryState('loading'); setDetail(null); setHistory([]); try { const assignment = await window.assignmentsService.getAssignmentById(assignmentId); setDetail(assignment); setDetailState('loaded'); const historyResult = await window.historyService.getBookHistory(knownBookId || assignment.bookId, { page: 1, per_page: 50 }); setHistory(historyResult.items); setHistoryState('loaded'); } catch (error) { if (error && error.status === 401) { handleSessionExpired(); return; } if (error && error.status === 404) setDetail(null); setDetailState('error'); setHistoryState('error'); setNotice({ tone: 'danger', text: errorMessage(error) }); } }; const refreshServerState = (assignmentId = null, bookId = null) => { setRefreshKey(value => value + 1); if (assignmentId) loadDetail(assignmentId, bookId); }; const openCreate = () => { setCreateForm({ book: null, stage: '', user_id: '', observations: '' }); setCreateBookSearch(''); setCreateBookResults([]); setCreateError(''); setCreateOpen(true); }; const submitCreate = async event => { event.preventDefault(); if (operationLocks.current.create || submittingCreate) return; const bookId = createForm.book && createForm.book.serverId; const userId = Number(createForm.user_id); if (!bookId || !createForm.stage || !Number.isInteger(userId) || userId < 1) { setCreateError('Selecciona un libro, una etapa y un usuario compatible.'); return; } operationLocks.current.create = true; setSubmittingCreate(true); setCreateError(''); try { const result = await window.assignmentsService.createAssignment({ book_id: bookId, user_id: userId, stage: createForm.stage, observations: createForm.observations.trim() || null }); setCreateOpen(false); setNotice({ tone: 'success', text: 'La asignación se creó correctamente.' }); refreshServerState(result.assignment && result.assignment.id, bookId); } catch (error) { const message = errorMessage(error, 'create'); setCreateError(message); if ([404, 409, 500, 0].includes(Number(error && error.status) || 0)) refreshServerState(); } finally { operationLocks.current.create = false; setSubmittingCreate(false); } }; const submitCancel = async event => { event.preventDefault(); if (operationLocks.current.cancel || submittingCancel || !cancelTarget) return; if (!cancelReason.trim()) { setCancelError('El motivo es obligatorio.'); return; } operationLocks.current.cancel = true; setSubmittingCancel(true); setCancelError(''); try { const result = await window.assignmentsService.cancelAssignment(cancelTarget.id, cancelReason.trim()); setCancelTarget(null); setNotice({ tone: 'success', text: 'La asignación se canceló correctamente.' }); refreshServerState(result.assignment && result.assignment.id, cancelTarget.bookId); } catch (error) { setCancelError(errorMessage(error, 'cancel')); if ([404, 409, 500, 0].includes(Number(error && error.status) || 0)) refreshServerState(); } finally { operationLocks.current.cancel = false; setSubmittingCancel(false); } }; const submitReassign = async event => { event.preventDefault(); if (operationLocks.current.reassign || submittingReassign || !reassignTarget) return; const userId = Number(reassignForm.user_id); if (!Number.isInteger(userId) || userId < 1 || userId === reassignTarget.userId) { setReassignError('Selecciona un usuario compatible distinto del usuario actual.'); return; } if (!reassignForm.reason.trim()) { setReassignError('El motivo es obligatorio.'); return; } operationLocks.current.reassign = true; setSubmittingReassign(true); setReassignError(''); try { const result = await window.assignmentsService.reassignAssignment( reassignTarget.id, userId, reassignForm.reason.trim() ); setReassignTarget(null); setNotice({ tone: 'success', text: 'La asignación se reasignó correctamente.' }); refreshServerState(result.assignment && result.assignment.id, reassignTarget.bookId); } catch (error) { setReassignError(errorMessage(error, 'reassign')); if ([404, 409, 500, 0].includes(Number(error && error.status) || 0)) refreshServerState(); } finally { operationLocks.current.reassign = false; setSubmittingReassign(false); } }; if (!canManage) { return ; } const compatibleCreateUsers = createForm.stage ? window.usersService.getCompatibleUsersForStage(users, createForm.stage) : []; const compatibleReassignUsers = reassignTarget ? window.usersService.getCompatibleUsersForStage(users, reassignTarget.stage) .filter(user => user.id !== reassignTarget.userId) : []; const activeAssignment = assignment => ['pending', 'in_progress'].includes(assignment.status); const alertStyle = tone => ({ padding: '10px 12px', borderRadius: 6, background: tone === 'success' ? 'var(--success-soft)' : 'var(--danger-soft)', color: tone === 'success' ? 'var(--success)' : 'var(--danger)', fontSize: 13 }); return (
refreshServerState()} onSessionExpired={handleSessionExpired} /> {notice && (
{notice.text}
)} !submittingCreate && setCreateOpen(false)} title="Nueva asignación" maxWidth={620} footer={<> }>
{createError &&
{createError}
}
{createForm.book ? (
#{createForm.book.inventoryNumber || '—'}
{createForm.book.title}
{createForm.book.author || 'Autor no registrado'} · {SUPERVISOR_STAGES[createForm.book.currentStage] || createForm.book.currentStage || '—'} · {createForm.book.status || '—'}
) : ( <> setCreateBookSearch(event.target.value)} placeholder="Buscar por título o inventario"/> {createBookLoading &&
Buscando…
} {createBookSearch.trim().length >= 2 && !createBookLoading && createBookResults.length === 0 &&
No se encontraron libros con ese criterio.
}
{createBookResults.map(book => )}
)}
{createForm.stage && compatibleCreateUsers.length === 0 &&
No existen usuarios activos compatibles con esta etapa.
}