feat: agregar boton de reporte

This commit is contained in:
2026-08-25 15:07:46 -04:00
parent 2b74335e03
commit d866a261d1
9 changed files with 581 additions and 131 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15 -11
View File
@@ -1,14 +1,18 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" /> <head>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="icon" type="image/jpeg"
<title>glm-card-generator</title> href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_white_background.jpg" />
<script type="module" crossorigin src="/empleado-id/assets/index-CVcbyLxj.js"></script> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" crossorigin href="/empleado-id/assets/index-DZLLXZoS.css"> <title>glm-card-generator</title>
</head> <script type="module" crossorigin src="/empleado-id/assets/index-EuHkvm43.js"></script>
<body> <link rel="stylesheet" crossorigin href="/empleado-id/assets/index-DRjn2cG3.css">
<div id="root"></div> </head>
</body>
<body>
<div id="root"></div>
</body>
</html> </html>
Binary file not shown.
+14 -10
View File
@@ -1,13 +1,17 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" /> <head>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="icon" type="image/jpeg"
<title>glm-card-generator</title> href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_white_background.jpg" />
</head> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<body> <title>glm-card-generator</title>
<div id="root"></div> </head>
<script type="module" src="/src/main.jsx"></script>
</body> <body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html> </html>
+263
View File
@@ -358,3 +358,266 @@ body {
color: #ffffff; color: #ffffff;
border-color: #4F758B; border-color: #4F758B;
} }
/* ── REPORT FLOATING BUTTON ── */
.report-fab {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 28px;
background: #4F758B;
color: #fff;
border: none;
box-shadow: 0 4px 20px rgba(79, 117, 139, 0.4);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: width 0.35s ease, background 0.2s ease, box-shadow 0.2s ease;
z-index: 1000;
overflow: hidden;
white-space: nowrap;
}
.report-fab:hover,
.report-fab.attention {
width: 150px;
}
.report-fab:hover {
background: #3d5d6e;
box-shadow: 0 6px 28px rgba(79, 117, 139, 0.5);
}
.report-fab.attention {
background: #E65100;
box-shadow: 0 6px 28px rgba(230, 81, 0, 0.55);
animation: fabPulse 1.2s ease-in-out infinite;
}
@keyframes fabPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
}
.report-fab-label {
max-width: 0;
opacity: 0;
overflow: hidden;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
transition: max-width 0.35s ease, opacity 0.25s ease, margin-right 0.35s ease;
}
.report-fab:hover .report-fab-label,
.report-fab.attention .report-fab-label {
max-width: 90px;
opacity: 1;
margin-right: 8px;
}
.report-fab:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(79, 117, 139, 0.3);
}
/* ── REPORT MODAL ── */
.report-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 1100;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.report-modal {
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow: hidden;
animation: slideUp 0.25s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.report-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #E0E0E0;
}
.report-modal-header h3 {
font-size: 15px;
font-weight: 700;
color: #4F758B;
letter-spacing: 0.5px;
}
.report-modal-close {
background: none;
border: none;
color: #6B8FA3;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.15s;
}
.report-modal-close:hover {
background: #F0F0F0;
color: #4F758B;
}
.report-modal-body {
padding: 20px;
}
.report-modal-hint {
font-size: 12px;
color: #6B8FA3;
margin-bottom: 10px;
font-weight: 500;
}
.report-modal-textarea {
width: 100%;
padding: 12px;
border: 1px solid #E0E0E0;
border-radius: 8px;
font-family: Arial, Helvetica, sans-serif;
font-size: 13px;
color: #2a2a2a;
resize: vertical;
min-height: 100px;
max-height: 300px;
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.report-modal-textarea:focus {
border-color: #6CC24A;
}
.report-modal-textarea::placeholder {
color: #B0B0B0;
}
.report-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid #E0E0E0;
background: #FAFAFA;
}
.report-modal-cancel,
.report-modal-send {
padding: 10px 20px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
font-family: Arial, Helvetica, sans-serif;
cursor: pointer;
transition: all 0.15s;
letter-spacing: 0.5px;
}
.report-modal-cancel {
background: #fff;
border: 1px solid #D0D0D0;
color: #4F758B;
}
.report-modal-cancel:hover {
background: #F0F0F0;
border-color: #B0B0B0;
}
.report-modal-send {
background: #6CC24A;
border: none;
color: #fff;
}
.report-modal-send:hover:not(:disabled) {
background: #5ab03f;
}
.report-modal-send:disabled {
background: #A0B2BC;
cursor: not-allowed;
}
/* ── REPORT MODAL SUCCESS STATE ── */
.report-modal-success {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 48px 24px;
animation: successPop 0.3s ease;
}
@keyframes successPop {
0% {
opacity: 0;
transform: scale(0.85);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.report-modal-success svg {
filter: drop-shadow(0 4px 12px rgba(108, 194, 74, 0.35));
}
.report-modal-success p {
font-size: 15px;
font-weight: 700;
color: #4F758B;
letter-spacing: 0.5px;
}
/* Responsive */
@media (max-width: 480px) {
.report-fab {
bottom: 16px;
right: 16px;
width: 50px;
height: 50px;
}
.report-modal {
margin: 12px;
max-height: calc(100vh - 24px);
}
}
+199 -21
View File
@@ -36,6 +36,12 @@ const convertBinaryToBlobUrl = (data) => {
return null; return null;
}; };
const friendlyError = (message) => {
const err = new Error(message);
err.friendly = true;
return err;
};
export default function IdCardGenerator() { export default function IdCardGenerator() {
// --- Estados formulario individual ─── // --- Estados formulario individual ───
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -45,9 +51,19 @@ export default function IdCardGenerator() {
const [employeeId, setEmployeeId] = useState(''); const [employeeId, setEmployeeId] = useState('');
const [photoSrc, setPhotoSrc] = useState(''); const [photoSrc, setPhotoSrc] = useState('');
const [photoFile, setPhotoFile] = useState(null); const [photoFile, setPhotoFile] = useState(null);
const [pendingPhotoFile, setPendingPhotoFile] = useState(null);
const [photoProcessing, setPhotoProcessing] = useState(false); const [photoProcessing, setPhotoProcessing] = useState(false);
const [downloadStatus, setDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' }); const [downloadStatus, setDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' });
// --- Estados Reporte de Errores/Ayuda ───
const [showReportModal, setShowReportModal] = useState(false);
const [reportMessage, setReportMessage] = useState('');
const [reportSending, setReportSending] = useState(false);
const [reportSent, setReportSent] = useState(false);
const reportCloseTimeoutRef = useRef(null);
const [fabAttention, setFabAttention] = useState(false);
const fabAttentionTimeoutRef = useRef(null);
// Bloquea el botón Descargar hasta que nombre, puesto e ID estén llenos // Bloquea el botón Descargar hasta que nombre, puesto e ID estén llenos
const isFormComplete = name.trim() && role.trim() && employeeId.trim(); const isFormComplete = name.trim() && role.trim() && employeeId.trim();
@@ -57,6 +73,53 @@ export default function IdCardGenerator() {
const [bulkDownloadStatus, setBulkDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' }); const [bulkDownloadStatus, setBulkDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' });
const [showTooltip, setShowTooltip] = useState(false); const [showTooltip, setShowTooltip] = useState(false);
const handleSendReport = async () => {
if (!reportMessage.trim() || reportSending) {
return;
}
setReportSending(true);
try {
const response = await fetch('https://agenteit.digitalcompass.agency/webhook/reporte-carnet', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subject: 'Reporte - Generador de ID',
body: reportMessage.trim(),
fecha: new Date().toISOString()
})
});
if (!response.ok) {
throw new Error(`El servidor rechazó la petición (Código: ${response.status})`);
}
setReportMessage('');
setReportSent(true);
reportCloseTimeoutRef.current = setTimeout(() => {
setShowReportModal(false);
setReportSent(false);
}, 1500);
} catch (error) {
console.error('Error al enviar el reporte:', error);
triggerFabAttention();
alert('❌ No pudimos enviar tu reporte. Revisa tu conexión e inténtalo de nuevo.');
} finally {
setReportSending(false);
}
};
const triggerFabAttention = () => {
setFabAttention(true);
clearTimeout(fabAttentionTimeoutRef.current);
fabAttentionTimeoutRef.current = setTimeout(() => {
setFabAttention(false);
}, 5000);
};
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const excelInputRef = useRef(null); const excelInputRef = useRef(null);
const qrCanvasRef = useRef(null); const qrCanvasRef = useRef(null);
@@ -71,26 +134,42 @@ export default function IdCardGenerator() {
} }
}, [employeeId]); }, [employeeId]);
const handlePhotoUpload = async (e) => { const processAndSetPhoto = async (file) => {
const file = e.target.files[0];
if (!file) return;
setPhotoFile(file);
setPhotoSrc(URL.createObjectURL(file));
setPhotoProcessing(true); setPhotoProcessing(true);
setPendingPhotoFile(file);
try { try {
const editedBlob = await processPhotoWithAI(file); const editedBlob = await processPhotoWithAI(file);
const editedUrl = URL.createObjectURL(editedBlob); const editedUrl = URL.createObjectURL(editedBlob);
setPhotoSrc(editedUrl); setPhotoSrc(editedUrl);
setPhotoFile(new File([editedBlob], file.name, { type: editedBlob.type || 'image/jpeg' })); setPhotoFile(new File([editedBlob], file.name, { type: editedBlob.type || 'image/jpeg' }));
setPendingPhotoFile(null);
} catch (error) { } catch (error) {
alert('No se pudo procesar la foto con IA. Se usará la foto original para la vista previa.'); console.error('Error al procesar la foto con IA:', error);
triggerFabAttention();
setPhotoSrc('');
setPhotoFile(null);
alert('No pudimos mejorar tu foto. Por favor intenta nuevamente; si sigue fallando, repórtanos el error con el botón de ayuda.');
} finally { } finally {
setPhotoProcessing(false); setPhotoProcessing(false);
} }
}; };
const handlePhotoUpload = (e) => {
const file = e.target.files[0];
e.target.value = '';
if (!file || photoProcessing) return;
setPhotoSrc(URL.createObjectURL(file));
processAndSetPhoto(file);
};
const handleRetryPhotoAI = () => {
if (!pendingPhotoFile || photoProcessing) return;
setPhotoSrc(URL.createObjectURL(pendingPhotoFile));
processAndSetPhoto(pendingPhotoFile);
};
const processPhotoWithAI = async (file) => { const processPhotoWithAI = async (file) => {
const N8N_WEBHOOK_URL = import.meta.env.VITE_N8N_WEBHOOK_URL; const N8N_WEBHOOK_URL = import.meta.env.VITE_N8N_WEBHOOK_URL;
const TOKEN_SECRETO = import.meta.env.VITE_WEBHOOK_TOKEN; const TOKEN_SECRETO = import.meta.env.VITE_WEBHOOK_TOKEN;
@@ -111,7 +190,8 @@ export default function IdCardGenerator() {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`El servidor de n8n rechazó la petición (Código: ${response.status})`); console.error('n8n rechazó la petición (foto IA). Status:', response.status);
throw friendlyError('El servicio no está disponible en este momento. Espera un momento e inténtalo de nuevo.');
} }
const contentType = response.headers.get('content-type') || ''; const contentType = response.headers.get('content-type') || '';
@@ -119,13 +199,13 @@ export default function IdCardGenerator() {
const data = await response.json(); const data = await response.json();
const bin = data.edited || data.data || data.foto_processed_base64 || data.photo || data.binary || (Array.isArray(data) && (data[0]?.edited || data[0]?.data)); const bin = data.edited || data.data || data.foto_processed_base64 || data.photo || data.binary || (Array.isArray(data) && (data[0]?.edited || data[0]?.data));
const blobUrl = convertBinaryToBlobUrl(bin); const blobUrl = convertBinaryToBlobUrl(bin);
if (!blobUrl) throw new Error('n8n no devolvió un binario válido.'); if (!blobUrl) throw friendlyError('La foto procesada llegó dañada. Intenta subirla de nuevo.');
const blob = await (await fetch(blobUrl)).blob(); const blob = await (await fetch(blobUrl)).blob();
return blob; return blob;
} }
const blob = await response.blob(); const blob = await response.blob();
if (!blob || blob.size === 0) throw new Error('n8n devolvió un binario vacío.'); if (!blob || blob.size === 0) throw friendlyError('La foto procesada llegó vacía. Intenta subirla de nuevo.');
return blob; return blob;
}; };
@@ -154,7 +234,8 @@ export default function IdCardGenerator() {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`El servidor de n8n rechazó la petición (Código: ${response.status})`); console.error('n8n rechazó la petición (lote). Status:', response.status);
throw friendlyError('El servicio no está disponible en este momento. Espera un momento e inténtalo de nuevo.');
} }
const resData = await response.json(); const resData = await response.json();
@@ -197,7 +278,7 @@ export default function IdCardGenerator() {
const jsonRows = XLSXLib.utils.sheet_to_json(ws); const jsonRows = XLSXLib.utils.sheet_to_json(ws);
if (jsonRows.length === 0) { if (jsonRows.length === 0) {
setBulkStatusText('❌ El archivo está vacío.'); setBulkStatusText('❌ El archivo no tiene datos. Descarga la plantilla y agrega los colaboradores.');
return; return;
} }
@@ -245,12 +326,16 @@ export default function IdCardGenerator() {
setBulkDownloadStatus({ processing: false, text: '⬇ Descargar' }); setBulkDownloadStatus({ processing: false, text: '⬇ Descargar' });
} catch (innerErr) { } catch (innerErr) {
setBulkStatusText('Error de lectura. Revisa el formato de columnas.'); console.error('Error leyendo el Excel:', innerErr);
triggerFabAttention();
setBulkStatusText('❌ No pudimos leer el archivo. Descarga la plantilla oficial y verifica las columnas.');
} }
}; };
reader.readAsBinaryString(file); reader.readAsBinaryString(file);
} catch (err) { } catch (err) {
setBulkStatusText('Error cargando el motor XLSX.'); console.error('Error cargando la librería XLSX:', err);
triggerFabAttention();
setBulkStatusText('❌ Tuvimos un problema técnico. Recarga la página e inténtalo de nuevo.');
} }
}; };
@@ -297,7 +382,9 @@ export default function IdCardGenerator() {
setBulkEmployees([]); setBulkEmployees([]);
} catch (error) { } catch (error) {
setBulkStatusText(`Error al procesar: ${error.message || error}`); console.error('Error al procesar el lote:', error);
triggerFabAttention();
setBulkStatusText('❌ Algo salió mal al generar los carnets. Inténtalo de nuevo; si sigue fallando, avísanos con el botón de ayuda.');
} finally { } finally {
setBulkDownloadStatus({ processing: false, text: '⬇ Descargar' }); setBulkDownloadStatus({ processing: false, text: '⬇ Descargar' });
} }
@@ -341,7 +428,8 @@ export default function IdCardGenerator() {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`El servidor de n8n rechazó la petición (Código: ${response.status})`); console.error('n8n rechazó la petición (individual). Status:', response.status);
throw friendlyError('El servicio no está disponible en este momento. Espera un momento e inténtalo de nuevo.');
} }
await downloadIDCards({ await downloadIDCards({
@@ -357,16 +445,20 @@ export default function IdCardGenerator() {
await saveClienteProyecto(cleanCedula, selectedClient); await saveClienteProyecto(cleanCedula, selectedClient);
} }
} catch (err) { } catch (err) {
alert(err.message); console.error('Error al generar el carnet:', err);
triggerFabAttention();
alert(err?.friendly
? err.message
: 'Algo salió mal al generar el carnet. Inténtalo de nuevo; si sigue fallando, avísanos con el botón de ayuda.');
setDownloadStatus({ processing: false, text: '⬇ Descargar' }); setDownloadStatus({ processing: false, text: '⬇ Descargar' });
} }
}; };
const validateForm = () => { const validateForm = () => {
if (!name.trim()) { alert("Debe ingresar el nombre."); return false; } if (!name.trim()) { alert("Escribe el nombre del colaborador para continuar."); return false; }
if (!role.trim()) { alert("Debe ingresar el puesto."); return false; } if (!role.trim()) { alert("Escribe el puesto del colaborador para continuar."); return false; }
if (!employeeId.trim()) { alert("Debe ingresar el ID del empleado."); return false; } if (!employeeId.trim()) { alert("Escribe el ID o cédula del colaborador para continuar."); return false; }
if (!photoFile) { alert("Debe subir la foto del colaborador."); return false; } if (!photoFile) { alert("Sube la foto del colaborador para continuar."); return false; }
return true; return true;
}; };
@@ -488,6 +580,24 @@ export default function IdCardGenerator() {
{photoProcessing ? '⏳ Procesando foto con IA...' : 'Subir foto del colaborador'} {photoProcessing ? '⏳ Procesando foto con IA...' : 'Subir foto del colaborador'}
</div> </div>
<input type="file" accept="image/*" style={{ display: 'none' }} ref={fileInputRef} onChange={handlePhotoUpload} /> <input type="file" accept="image/*" style={{ display: 'none' }} ref={fileInputRef} onChange={handlePhotoUpload} />
{pendingPhotoFile && !photoProcessing && (
<>
<div style={{
fontSize: '11px', fontWeight: '600', color: '#E65100',
background: '#FFF3E0', border: '1px solid #FFCC80',
borderRadius: '4px', padding: '7px 10px'
}}>
La foto no pudo mejorarse. Inténtalo de nuevo.
</div>
<button
className="export-btn"
style={{ background: '#E65100' }}
onClick={handleRetryPhotoAI}
>
Reintentar mejora de foto
</button>
</>
)}
</div> </div>
<div className="divider"></div> <div className="divider"></div>
@@ -590,6 +700,74 @@ export default function IdCardGenerator() {
</div> </div>
</div> </div>
</div> </div>
<button
className={`report-fab ${fabAttention ? 'attention' : ''}`}
onClick={() => {
clearTimeout(fabAttentionTimeoutRef.current);
setFabAttention(false);
clearTimeout(reportCloseTimeoutRef.current);
setReportMessage('');
setReportSent(false);
setShowReportModal(true);
}}
aria-label="Reportar error o pedir ayuda"
title="Reportar error o pedir ayuda"
>
<span className="report-fab-label">Reportar</span>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</button>
{showReportModal && (
<div className="report-modal-overlay" onClick={() => { if (!reportSending && !reportSent) setShowReportModal(false); }}>
<div className="report-modal" onClick={(e) => e.stopPropagation()}>
{reportSent ? (
<div className="report-modal-success">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#6CC24A" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="8 12.5 11 15.5 16 9.5" />
</svg>
<p>Reporte enviado</p>
</div>
) : (
<>
<div className="report-modal-header">
<h3>Reportar error / Pedir ayuda</h3>
<button className="report-modal-close" onClick={() => { if (!reportSending) setShowReportModal(false); }} aria-label="Cerrar" disabled={reportSending}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="report-modal-body">
<p className="report-modal-hint">Describe el problema o tu solicitud:</p>
<textarea
className="report-modal-textarea"
value={reportMessage}
onChange={(e) => setReportMessage(e.target.value)}
placeholder="Ej: La foto no se procesa correctamente, el QR no se genera, necesito agregar un cliente nuevo, etc."
rows="5"
disabled={reportSending}
/>
</div>
<div className="report-modal-footer">
<button className="report-modal-cancel" onClick={() => { setShowReportModal(false); setReportMessage(''); }} disabled={reportSending}>
Cancelar
</button>
<button className="report-modal-send" onClick={handleSendReport} disabled={!reportMessage.trim() || reportSending}>
{reportSending ? '🔄 Enviando...' : 'Enviar'}
</button>
</div>
</>
)}
</div>
</div>
)}
</> </>
); );
} }
+2 -1
View File
@@ -36,7 +36,8 @@ export default function Login() {
}); });
if (authError) { if (authError) {
setError(authError.message); console.error('Error de autenticación:', authError);
setError('No pudimos iniciar sesión. Verifica tu conexión e inténtalo de nuevo.');
setLoading(false); setLoading(false);
} }
}; };