// ============================================================
// 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'}
))}
{/* 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={<>
>}>
!submittingCancel && setCancelTarget(null)} title="Cancelar asignación" maxWidth={520}
footer={<>
>}>
{cancelTarget && }
!submittingReassign && setReassignTarget(null)} title="Reasignar libro" maxWidth={520}
footer={<>
>}>
{reassignTarget && }
{ setDetail(null); setDetailState('idle'); setHistory([]); }} title="Detalle de asignación" maxWidth={760}>
{detailState === 'loading' && }
{detailState === 'error' && No fue posible cargar el detalle.
}
{detailState === 'loaded' && detail &&
| Libro | #{detail.book && detail.book.inventory_number} · {detail.book && detail.book.title} |
| Etapa / estado | {SUPERVISOR_STAGES[detail.stage]} · {SUPERVISOR_STATUS_LABELS[detail.status]} |
| Usuario | {detail.user && `${detail.user.name} (${detail.user.username})`} |
| Asignó | {detail.assignedByUser ? `${detail.assignedByUser.name} (${detail.assignedByUser.username})` : '—'} |
| Fechas | Asignada: {formatDateTime(detail.assignedAt)} · Inicio: {formatDateTime(detail.startedAt)} · Fin: {formatDateTime(detail.completedAt || detail.cancelledAt)} |
| Observaciones | {detail.observations || '—'} |
Historial del libro
{historyState === 'loading' &&
Cargando historial…
}
{historyState === 'error' &&
No fue posible cargar el historial.
}
{historyState === 'loaded' && history.length === 0 &&
No hay eventos registrados.
}
{historyState === 'loaded' && history.map(event =>
{SUPERVISOR_HISTORY_LABELS[event.action] || event.action}{formatDateTime(event.effectiveAt || event.createdAt)}
{event.user ? event.user.name : 'Sistema'}{event.observations ? ` · ${event.observations}` : ''}
)}
}
);
};
const Supervisor = props => window.APP_CONFIG.AUTH.MODE === 'server'
?
: ;
Object.assign(window, { Supervisor });