// ============================================================
// Módulo Edición - con cortes horarios 11am / 1pm / 4pm / 6pm
// ============================================================
const CORTES = [
{ key: 'c11', hora: '11:00', label: 'Corte AM' },
{ key: 'c13', hora: '13:00', label: 'Pre-comida' },
{ key: 'c16', hora: '16:00', label: 'Post-comida' },
{ key: 'c18', hora: '18:00', label: 'Cierre' },
];
const EdicionLocal = ({ libros, currentUser, onUpdate, onSelectBook }) => {
const [editando, setEditando] = useState(null);
const [imagenes, setImagenes] = useState('');
const [cortesForm, setCortesForm] = useState({ c11: '', c13: '', c16: '', c18: '' });
// Cola FIFO: libros digitalizados ordenados por fecha, asignados al editor o disponibles
const misEnCurso = libros.filter(l => l.estatus === 'en_edicion' && l.editadoPor === currentUser.id);
const disponibles = libros.filter(l => l.estatus === 'digitalizado')
.sort((a, b) => a.fechaDigitalizacion.localeCompare(b.fechaDigitalizacion));
const misEditados = libros.filter(l =>
l.editadoPor === currentUser.id &&
['editado','en_cc','aceptado','rechazado','en_conversion','finalizado'].includes(l.estatus)
);
// Producción de hoy (cortes)
const hoyEditados = misEditados.filter(l => l.fechaEdicion === '2026-08-02');
const cortesHoy = { c11: 0, c13: 0, c16: 0, c18: 0 };
hoyEditados.forEach(l => {
if (l.cortes) {
cortesHoy.c11 += l.cortes.c11 || 0;
cortesHoy.c13 += l.cortes.c13 || 0;
cortesHoy.c16 += l.cortes.c16 || 0;
cortesHoy.c18 += l.cortes.c18 || 0;
}
});
const totalCortesHoy = cortesHoy.c11 + cortesHoy.c13 + cortesHoy.c16 + cortesHoy.c18;
// Hora "actual" simulada
const horaActual = 15; // 3 PM
const cortesEsperados = [11, 13, 16, 18];
const cortesCompletados = cortesEsperados.filter(h => h <= horaActual).length;
const tomarLibro = (libro) => {
onUpdate(libro.cons, { estatus: 'en_edicion', editadoPor: currentUser.id });
};
const abrirRegistro = (libro) => {
setEditando(libro);
setImagenes(libro.imagenesResultantes?.toString() || '');
setCortesForm({ c11: '', c13: '', c16: '', c18: '' });
};
const finalizarEdicion = () => {
if (!editando) return;
const n = parseInt(imagenes, 10);
if (isNaN(n) || n <= 0) return;
onUpdate(editando.cons, {
estatus: 'editado',
fechaEdicion: '2026-08-02',
editadoPor: currentUser.id,
imagenesEditadas: n,
cortes: {
c11: parseInt(cortesForm.c11) || 0,
c13: parseInt(cortesForm.c13) || 0,
c16: parseInt(cortesForm.c16) || 0,
c18: parseInt(cortesForm.c18) || 0,
},
});
setEditando(null);
setImagenes('');
};
return (
{/* Timeline de cortes de hoy */}
Registro de productividad de hoy · Sábado 02 Agosto
Jornada L-V 9:00-18:00 · Comida 14:00-15:00 · Cortes en 11:00, 13:00, 16:00 y 18:00
{CORTES.map((c, i) => {
const val = cortesHoy[c.key];
const hora = parseInt(c.hora);
const isDone = hora <= horaActual;
const isActive = false; // ninguno activo en este demo
return (
{isDone ? '✓' : i + 1}
{c.hora}
{val}
{c.label}
);
})}
Total imágenes editadas hoy
{fmtNum(totalCortesHoy)}
Ritmo por hora (estimado)
{cortesCompletados > 0 ? Math.round(totalCortesHoy / (horaActual - 9)) : 0} img/hr
Libros terminados hoy
{hoyEditados.length}
{/* Kanban personal */}
En edición ({misEnCurso.length})
Libros que estoy editando ahora
{misEnCurso.length === 0 &&
}
{misEnCurso.map(l => (
abrirRegistro(l)}>
#{l.cons} · {l.serie}
{l.titulo}
{fmtNum(l.imagenesResultantes)} imágenes
Digitalizado {fmtDate(l.fechaDigitalizacion)}
))}
Cola FIFO disponible ({disponibles.length})
Ordenados por fecha de digitalización · Primeros en entrar, primeros en salir
{disponibles.length === 0 &&
}
{disponibles.slice(0, 12).map((l, i) => {
const dias = daysBetween(l.fechaDigitalizacion, '2026-08-02');
return (
5 ? 'urgent' : ''}`}>{i + 1}
#{l.cons} · {l.serie}
{l.titulo}
{fmtNum(l.imagenesResultantes)} imág · esperando {dias} día{dias === 1 ? '' : 's'}
{dias > 5 && Urgente}
);
})}
{/* Historial */}
Mis libros editados
Últimos {Math.min(10, misEditados.length)} · click para ver detalle
| Núm. |
Título |
Fecha edición |
Imágs. |
Cortes 11/13/16/18 |
Estatus |
{misEditados.slice(-10).reverse().map(l => (
onSelectBook(l)} style={{ cursor: 'pointer' }}>
| #{l.cons} |
{l.titulo} |
{fmtDate(l.fechaEdicion)} |
{fmtNum(l.imagenesEditadas)} |
{l.cortes ? `${l.cortes.c11}/${l.cortes.c13}/${l.cortes.c16}/${l.cortes.c18}` : '—'}
|
|
))}
{/* Modal finalizar */}
setEditando(null)}
title="Finalizar edición y registrar cortes"
maxWidth={520}
footer={<>
>}
>
{editando && (
#{editando.cons}
{editando.titulo}
{editando.autor} · {fmtNum(editando.imagenesResultantes)} imágenes originales
setImagenes(e.target.value)} autoFocus/>
Cortes horarios (imágenes procesadas en cada tramo)
)}
);
};
const EDITING_CUTS = [
{ key: 'cut_11', label: '11:00 · Corte AM' },
{ key: 'cut_13', label: '13:00 · Pre-comida' },
{ key: 'cut_16', label: '16:00 · Post-comida' },
{ key: 'cut_18', label: '18:00 · Cierre' },
];
const editingIsoDate = (value) => {
if (!value) return '';
const normalized = String(value).replace(' ', 'T');
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10);
};
const editingFormatDate = (value) => {
const iso = editingIsoDate(value);
if (!iso) return '—';
const [y,m,d] = iso.split('-');
return `${d}/${m}/${y}`;
};
const EdicionServer = ({ currentUser, onSessionExpired }) => {
const [state, setState] = useState({status:'loading',error:'',available:[],active:[],progress:[],completed:[],today:{date:'',total_images:0,finished_books:0}});
const [reloadToken,setReloadToken]=useState(0);
const [feedback,setFeedback]=useState(null);
const [claimBook,setClaimBook]=useState(null);
const [claimBusy,setClaimBusy]=useState(false);
const [workBook,setWorkBook]=useState(null);
const [workDate,setWorkDate]=useState(new Date().toISOString().slice(0,10));
const [cuts,setCuts]=useState({cut_11:'',cut_13:'',cut_16:'',cut_18:''});
const [images,setImages]=useState([]);
const [finishMode,setFinishMode]=useState(false);
const [saving,setSaving]=useState(false);
const [downloadBusy,setDownloadBusy]=useState({});
const [search,setSearch]=useState('');
useEffect(()=>{
let active=true; const controller=new AbortController();
window.apiRequest('editing/dashboard.php',{signal:controller.signal}).then(response=>{
if(!active)return; const data=response?.data||{};
setState({status:'loaded',error:'',available:data.available||[],active:data.active||[],progress:data.progress||[],completed:data.completed||[],today:data.today||{date:'',total_images:0,finished_books:0}});
}).catch(error=>{
if(!active)return; if(error?.status===401){onSessionExpired();return;}
setState(s=>({...s,status:'error',error:error?.message||'No fue posible cargar Edición.'}));
});
return()=>{active=false;controller.abort();};
},[reloadToken,onSessionExpired]);
const refresh=()=>setReloadToken(v=>v+1);
const filteredAvailable=state.available.filter(book=>{
const q=search.trim().toLowerCase(); if(!q)return true;
return [book.inventory_number,book.catalog_number,book.series_collection,book.title,book.author].some(v=>String(v||'').toLowerCase().includes(q));
});
const downloadEditingZip=async(book)=>{
const assignmentId=Number(book?.assignment_id||0);
if(!assignmentId)return;
setDownloadBusy(previous=>({...previous,[assignmentId]:true}));
setFeedback(null);
try{
const base=window.APP_CONFIG.API_BASE_URL.replace(/\/+$/,'');
const response=await fetch(`${base}/editing/download.php?assignment_id=${encodeURIComponent(assignmentId)}`,{
method:'GET',
credentials:'include',
headers:{Accept:'application/zip,application/json'},
cache:'no-store'
});
const contentType=response.headers.get('content-type')||'';
if(!response.ok||!contentType.includes('application/zip')){
let message=`No fue posible descargar el ZIP. HTTP ${response.status}`;
try{
const payload=await response.json();
if(payload?.message)message=payload.message;
}catch(error){}
throw Object.assign(new Error(message),{status:response.status});
}
const blob=await response.blob();
if(!blob||blob.size<1)throw new Error('El ZIP generado está vacío.');
const disposition=response.headers.get('content-disposition')||'';
const utf8Match=disposition.match(/filename\*=UTF-8''([^;]+)/i);
const simpleMatch=disposition.match(/filename="?([^";]+)"?/i);
const fileName=utf8Match
? decodeURIComponent(utf8Match[1])
: simpleMatch
? simpleMatch[1]
: `INPI_2026_EDICION_${new Date().toISOString().replace(/[-:T]/g,'_').slice(0,19)}_ASIGNACION_${assignmentId}.zip`;
const objectUrl=URL.createObjectURL(blob);
const anchor=document.createElement('a');
anchor.href=objectUrl;
anchor.download=fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(()=>URL.revokeObjectURL(objectUrl),1500);
setFeedback({tone:'success',message:`El ZIP de "${book.title||'la obra'}" se descargó nuevamente.`});
}catch(error){
if(error?.status===401){onSessionExpired();return;}
setFeedback({tone:'error',message:error?.message||'No fue posible descargar nuevamente el ZIP.'});
}finally{
setDownloadBusy(previous=>{
const next={...previous};
delete next[assignmentId];
return next;
});
}
};
const confirmClaim=async()=>{
if(!claimBook)return;
setClaimBusy(true);
setFeedback(null);
let claimedAssignmentId=null;
try{
const response=await window.apiRequest('editing/claim.php',{method:'POST',body:{book_id:claimBook.id}});
const data=response?.data||{};
claimedAssignmentId=Number(data.assignment?.id)||null;
const url=`${window.APP_CONFIG.API_BASE_URL.replace(/\/+$/,'')}/${data.download_url}`;
const downloadResponse=await fetch(url,{method:'GET',credentials:'include',headers:{Accept:'application/zip,application/json'}});
const contentType=downloadResponse.headers.get('content-type')||'';
if(!downloadResponse.ok||!contentType.includes('application/zip')){
let message=`No fue posible descargar el ZIP. HTTP ${downloadResponse.status}`;
try{
const raw=await downloadResponse.text();
const payload=JSON.parse(raw);
if(payload?.message)message=payload.message;
}catch(error){}
throw Object.assign(new Error(message),{status:downloadResponse.status});
}
const blob=await downloadResponse.blob();
if(!blob||blob.size<1)throw new Error('El ZIP generado está vacío.');
const objectUrl=URL.createObjectURL(blob);
const a=document.createElement('a');
a.href=objectUrl;
a.download=data.download_filename||`INPI_2026_${new Date().toISOString().slice(0,10).split('-').reverse().join('_')}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(()=>URL.revokeObjectURL(objectUrl),1500);
setClaimBook(null);
setFeedback({tone:'success',message:`Libro tomado y ZIP descargado correctamente (${data.source_file_count||0} imágenes).`});
refresh();
}catch(error){
if(claimedAssignmentId){
try{
await window.apiRequest('editing/release.php',{method:'POST',body:{assignment_id:claimedAssignmentId}});
}catch(releaseError){}
}
if(error?.status===401){onSessionExpired();return;}
setFeedback({tone:'error',message:error?.message||'No fue posible tomar o descargar el libro. El libro se liberó de la cola de Edición.'});
refresh();
}finally{
setClaimBusy(false);
}
};
const openWork=(book,finish=false)=>{
setWorkBook(book);setFinishMode(finish);setWorkDate(state.today.date||new Date().toISOString().slice(0,10));
const existing=state.progress.find(p=>Number(p.assignment_id)===Number(book.assignment_id)&&p.work_date===(state.today.date||new Date().toISOString().slice(0,10)));
setCuts({cut_11:existing?.cut_11||'',cut_13:existing?.cut_13||'',cut_16:existing?.cut_16||'',cut_18:existing?.cut_18||''});setImages([]);
};
const saveProgress=async()=>{
if(!workBook)return;setSaving(true);setFeedback(null);
try{
if(finishMode){
if(images.length===0)throw new Error('Selecciona las imágenes editadas.');
const form=new FormData();
form.append('assignment_id',workBook.assignment_id);
images.forEach(file=>form.append('images[]',file));
const response=await fetch(`${window.APP_CONFIG.API_BASE_URL.replace(/\/+$/,'')}/editing/complete.php`,{method:'POST',credentials:'include',body:form});
const raw=await response.text();
let payload=null;
try{payload=raw?JSON.parse(raw):null;}catch(error){}
if(!response.ok||payload?.success===false)throw Object.assign(new Error(payload?.message||`HTTP ${response.status}`),{status:response.status});
setFeedback({tone:'success',message:'Edición finalizada, imágenes guardadas y libro enviado a Control de Calidad.'});
}else{
await window.apiRequest('editing/save-progress.php',{method:'POST',body:{assignment_id:workBook.assignment_id,...cuts}});
setFeedback({tone:'success',message:'Avance del día guardado correctamente.'});
}
setWorkBook(null);refresh();
}catch(error){if(error?.status===401){onSessionExpired();return;}setFeedback({tone:'error',message:error?.message||'No fue posible guardar.'});}
finally{setSaving(false);}
};
const todayProgress=state.progress.filter(p=>p.work_date===state.today.date);
const todayCuts={cut_11:0,cut_13:0,cut_16:0,cut_18:0};todayProgress.forEach(p=>Object.keys(todayCuts).forEach(k=>todayCuts[k]+=Number(p[k]||0)));
if(state.status==='error')return ;
return
{feedback&&
{feedback.message}
}
Historial y productividad de hoy · {editingFormatDate(state.today.date)}
Cortes diarios conservados por fecha; si un libro continúa mañana, se crea un nuevo registro
{EDITING_CUTS.map((c,i)=>
0?'done':''}`}>{todayCuts[c.key]>0?'✓':i+1}
{c.label.split(' · ')[0]}
{fmtNum(todayCuts[c.key])}
{c.label.split(' · ')[1]}
)}
Total imágenes editadas hoy
{fmtNum(state.today.total_images)}
Libros terminados hoy
{state.today.finished_books}
{state.progress.length>0&&
| Fecha | Libro | 11:00 | 13:00 | 16:00 | 18:00 | Total |
{state.progress.slice(0,12).map(p=>| {editingFormatDate(p.work_date)} | {p.title} | {p.cut_11} | {p.cut_13} | {p.cut_16} | {p.cut_18} | {p.total_images} |
)}
}
En edición ({state.active.length})
Libros que tomaste y están en proceso
{state.active.length===0&&
}{state.active.map(book=>
#{book.inventory_number} · {book.series_collection}
{book.title}
{fmtNum(book.resulting_images)} imágenes originalesTomado {editingFormatDate(book.assigned_at)}
)}
Cola FIFO disponible ({filteredAvailable.length})
Digitalizados y sin asignación activa de Edición
setSearch(e.target.value)}/>
{filteredAvailable.length===0&&
}{filteredAvailable.map((book,i)=>
{i+1}
#{book.inventory_number} · {book.series_collection}
{book.title}
{fmtNum(book.resulting_images)} imágenes · digitalizado {editingFormatDate(book.digitized_at)}
)}
Mis libros editados
Historial real de asignaciones completadas
| Núm. | Título | Fecha | Imágenes | Días trabajados | Estatus |
{state.completed.map(row=>| #{row.inventory_number} | {row.title} | {editingFormatDate(row.completed_at)} | {row.edited_images} | {row.work_days} | |
)}
!claimBusy&&setClaimBook(null)} title="Preparar descarga para edición" maxWidth={560} footer={<>>}>
{claimBook&&#{claimBook.inventory_number}
{claimBook.title}
Antes de descargarCrea esta estructura para mantener el control:
Documentos\INPI 2026\EDICION\{editingFormatDate(state.today.date||new Date().toISOString().slice(0,10)).replaceAll('/','_')}\
Al aceptar, el libro se asignará exclusivamente a tu usuario y se descargará un ZIP con la estructura Serie\Título\1.JPG\.
}
!saving&&setWorkBook(null)} title={finishMode?'Finalizar edición y subir imágenes':'Registrar avance diario'} maxWidth={620} footer={<>>}>
{workBook&&
#{workBook.inventory_number} · {workBook.series_collection||'Sin serie'}
{workBook.title}
{workBook.author||'Sin autor'} · {fmtNum(workBook.resulting_images)} imágenes originales
{!finishMode&&<>
Cortes horarios del día
>}
{finishMode&&
setImages(Array.from(e.target.files||[]))}/>
Se guardarán en INPI 2026\EDICION\Serie\Título\1.JPG\ conservando el nombre original.
{images.length} archivos seleccionados
}
}
;
};
const Edicion = (props) => {
const serverMode = window.APP_CONFIG.AUTH.MODE === 'server';
if (serverMode) return ;
return ;
};
Object.assign(window, { Edicion });