498 lines
22 KiB
TypeScript
498 lines
22 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { KeyRound, Trash2, Copy, Check, Download, AlertTriangle, ShieldCheck, X } from 'lucide-react';
|
|
import { ApiService } from '../services/api';
|
|
import { ConfirmModal } from './ConfirmModal';
|
|
|
|
interface AdminPanelProps {
|
|
rawVotes: Record<string, string>;
|
|
onVoteResetSuccess: () => Promise<void>;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const AdminPanel: React.FC<AdminPanelProps> = ({
|
|
rawVotes,
|
|
onVoteResetSuccess,
|
|
isOpen,
|
|
onClose,
|
|
}) => {
|
|
const [selectedVoter, setSelectedVoter] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [adminMsg, setAdminMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const [copiedCode, setCopiedCode] = useState(false);
|
|
const [copiedSingleHtml, setCopiedSingleHtml] = useState(false);
|
|
const [showConfirmResetAll, setShowConfirmResetAll] = useState(false);
|
|
|
|
const registeredVoters = Object.keys(rawVotes).sort();
|
|
|
|
const handleResetVote = async () => {
|
|
if (!selectedVoter) {
|
|
setAdminMsg({ text: 'Selecciona un votante de la lista.', type: 'error' });
|
|
return;
|
|
}
|
|
|
|
const trimmedPass = password.trim();
|
|
if (!trimmedPass) {
|
|
setAdminMsg({ text: 'Ingresa la contraseña de autorización.', type: 'error' });
|
|
return;
|
|
}
|
|
|
|
setIsDeleting(true);
|
|
setAdminMsg(null);
|
|
|
|
try {
|
|
await ApiService.deleteVote(selectedVoter, trimmedPass);
|
|
await onVoteResetSuccess();
|
|
setAdminMsg({
|
|
text: `Voto de "${selectedVoter}" removido exitosamente del sistema.`,
|
|
type: 'success',
|
|
});
|
|
setSelectedVoter('');
|
|
setPassword('');
|
|
} catch (err) {
|
|
setAdminMsg({
|
|
text: err instanceof Error ? err.message : 'Error al remover voto',
|
|
type: 'error',
|
|
});
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
const handlePromptResetAll = () => {
|
|
const trimmedPass = password.trim();
|
|
if (!trimmedPass) {
|
|
setAdminMsg({ text: 'Ingresa la contraseña de autorización para vaciar todos los votos.', type: 'error' });
|
|
return;
|
|
}
|
|
setAdminMsg(null);
|
|
setShowConfirmResetAll(true);
|
|
};
|
|
|
|
const handleExecuteResetAll = async () => {
|
|
setShowConfirmResetAll(false);
|
|
const trimmedPass = password.trim();
|
|
setIsDeleting(true);
|
|
setAdminMsg(null);
|
|
|
|
try {
|
|
const res = await ApiService.deleteAllVotes(trimmedPass);
|
|
await onVoteResetSuccess();
|
|
setAdminMsg({
|
|
text: `Todos los votos han sido removidos (${res.count} votos eliminados).`,
|
|
type: 'success',
|
|
});
|
|
setSelectedVoter('');
|
|
setPassword('');
|
|
} catch (err) {
|
|
setAdminMsg({
|
|
text: err instanceof Error ? err.message : 'Error al vaciar votos',
|
|
type: 'error',
|
|
});
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
const codeSnippet = `// Votos embebidos en código fuente — Consultoría E3
|
|
const VOTOS_EMBEBIDOS = ${JSON.stringify(rawVotes, null, 2)};`;
|
|
|
|
const handleCopyCode = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(codeSnippet);
|
|
setCopiedCode(true);
|
|
setTimeout(() => setCopiedCode(false), 2500);
|
|
} catch {
|
|
// fallback
|
|
}
|
|
};
|
|
|
|
const generateStandaloneHtml = () => {
|
|
return `<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>¿Qué proyecto debe ser el ganador? — Consultoría E3</title>
|
|
<meta name="description" content="Encuesta interna de Consultoría E3 para elegir al proyecto ganador en tiempo real.">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg: #000000;
|
|
--surface: #0a0a0a;
|
|
--accent: #00B7D4;
|
|
--text-primary: #ffffff;
|
|
--text-secondary: #cfd6d9;
|
|
--glass: rgba(255, 255, 255, 0.04);
|
|
--glass-border: rgba(255, 255, 255, 0.08);
|
|
--green-live: #00e676;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
background-color: var(--bg);
|
|
color: var(--text-primary);
|
|
font-family: 'Poppins', sans-serif;
|
|
min-height: 100vh;
|
|
padding: 24px 16px 60px;
|
|
}
|
|
.wrap { max-width: 1100px; margin: 0 auto; }
|
|
.dashboard { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 24px; }
|
|
.glass-panel {
|
|
background: var(--glass);
|
|
border: 1px solid var(--glass-border);
|
|
border-radius: 20px;
|
|
backdrop-filter: blur(16px);
|
|
padding: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.title-font { font-family: 'Bebas Neue', sans-serif; text-transform: uppercase; letter-spacing: 1.5px; }
|
|
.accent-text { color: var(--accent); }
|
|
.btn-pill {
|
|
background: transparent; border: 1px solid var(--glass-border); color: var(--text-secondary); padding: 12px 16px; border-radius: 100px; font-size: 13px; text-transform: uppercase; letter-spacing: 1px; transition: all 0.2s; cursor: pointer; text-align: center;
|
|
}
|
|
.btn-pill:hover:not(:disabled) { background: rgba(0, 183, 212, 0.1); border-color: var(--accent); color: #fff; }
|
|
.btn-pill.active { background: var(--accent); color: var(--bg); border-color: var(--accent); font-weight: 700; box-shadow: 0 0 15px rgba(0, 183, 212, 0.4); }
|
|
.live-indicator { display: inline-flex; align-items: center; gap: 8px; font-size: 11px; text-transform: uppercase; color: var(--green-live); letter-spacing: 1px; }
|
|
.dot { width: 8px; height: 8px; background: var(--green-live); border-radius: 50%; box-shadow: 0 0 8px var(--green-live); animation: pulse 2s infinite; }
|
|
@keyframes pulse { 0%,100%{ opacity:1; } 50%{ opacity:0.3; } }
|
|
input[type="text"], input[type="password"], select {
|
|
width: 100%; padding: 14px; background: rgba(255,255,255,0.04); border: 1px solid var(--glass-border); border-radius: 12px; color: #fff; font-size: 14px; font-family: inherit; text-transform: uppercase; outline: none; transition: 0.2s;
|
|
}
|
|
input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 15px rgba(0,183,212,0.25); }
|
|
.pills-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }
|
|
.bar-row { display: grid; grid-template-columns: 100px 1fr auto; align-items: center; gap: 12px; margin-bottom: 12px; }
|
|
.bar-track { height: 8px; background: rgba(255,255,255,0.1); border-radius: 10px; overflow: hidden; }
|
|
.bar-fill { height: 100%; background: var(--text-secondary); border-radius: 10px; transition: width 0.5s ease; }
|
|
.bar-fill.leader { background: var(--accent); box-shadow: 0 0 10px rgba(0, 183, 212, 0.4); }
|
|
.badge { font-size: 9px; background: var(--accent); color: var(--bg); padding: 2px 6px; border-radius: 4px; font-weight: 800; }
|
|
.btn-action { width: 100%; padding: 14px; border-radius: 12px; background: var(--accent); color: #000; font-weight: 800; text-transform: uppercase; letter-spacing: 1.5px; border: none; cursor: pointer; transition: 0.2s; margin-top: 16px; box-shadow: 0 0 15px rgba(0,183,212,0.3); }
|
|
.btn-action:hover { background: #38bdf8; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="dashboard">
|
|
<section class="glass-panel">
|
|
<div style="margin-bottom: 24px;">
|
|
<div class="live-indicator" style="margin-bottom: 8px;"><span class="dot"></span> SINCRONIZADO EN VIVO</div>
|
|
<h1 class="title-font" style="font-size: 38px;">CONSULTORÍA <span class="accent-text">E3</span></h1>
|
|
<p style="font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px;">Sistema de Votación Global 2026</p>
|
|
</div>
|
|
<div style="margin-bottom: 20px;">
|
|
<label style="display:block; font-size:11px; text-transform:uppercase; color:#888; font-weight:600; margin-bottom:6px;">Paso 1: Identificación</label>
|
|
<input type="text" id="voterInput" placeholder="ESCRIBE TU NOMBRE COMPLETO">
|
|
</div>
|
|
<div style="flex:1;">
|
|
<label style="display:block; font-size:11px; text-transform:uppercase; color:#888; font-weight:600; margin-bottom:8px;">Paso 2: Elige el Proyecto Ganador</label>
|
|
<div class="pills-grid" id="pillsWrap"></div>
|
|
</div>
|
|
<div id="confirmBox" style="margin-top:14px;"></div>
|
|
<button type="button" class="btn-action" id="btnCastVote">REGISTRAR VOTO ATÓMICO</button>
|
|
</section>
|
|
|
|
<section class="glass-panel">
|
|
<div style="display:flex; justify-content:space-between; align-items:flex-end; margin-bottom: 20px;">
|
|
<div>
|
|
<h2 class="title-font" style="font-size: 26px;">RESULTADOS <span class="accent-text">REAL-TIME</span></h2>
|
|
<p style="font-size: 11px; color: #888; font-family: monospace;" id="totalVotosTxt">TOTAL DE VOTOS: 0</p>
|
|
</div>
|
|
<div style="text-align:right;">
|
|
<span class="title-font accent-text" style="font-size: 26px;" id="pctTxt">0%</span>
|
|
<p style="font-size: 9px; color: #888; text-transform: uppercase;">Actividad</p>
|
|
</div>
|
|
</div>
|
|
<div id="barsWrap" style="flex:1;"></div>
|
|
<div style="margin-top:20px; border-top:1px solid var(--glass-border); pt:14px; display:flex; justify-content:space-between; align-items:center; font-size:10px; font-family:monospace; color:#666;">
|
|
<div>ENDPOINT: WORTHY-DUCK-110283</div>
|
|
<div style="cursor:pointer; color:#aaa;" onclick="syncVotes()">ACTUALIZAR ⟳</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const CONFIG = {
|
|
endpoint: "https://worthy-duck-110283.upstash.io",
|
|
token: "gQAAAAAAAa7LAQIgcDE5Yjc5NzdlMmI1ODk0NjA0ODhhZDViNTNmOTViYTdkYg",
|
|
key: "e3_consultoria_encuesta_2026"
|
|
};
|
|
const CANDIDATOS = ["SERVANDO", "URIEL", "ANNA", "YAMILET", "VERONICA", "ERICK", "MELINA", "LEYMI", "GRECIA"];
|
|
let state = { votes: ${JSON.stringify(rawVotes)}, currentVoter: localStorage.getItem('e3_voter_app') || '', selected: null };
|
|
|
|
async function callRedis(cmd) {
|
|
const res = await fetch(CONFIG.endpoint, { method: "POST", headers: { "Authorization": "Bearer " + CONFIG.token, "Content-Type": "application/json" }, body: JSON.stringify(cmd) });
|
|
const data = await res.json();
|
|
return data.result;
|
|
}
|
|
|
|
async function syncVotes() {
|
|
try {
|
|
const res = await callRedis(["HGETALL", CONFIG.key]);
|
|
const votes = {};
|
|
if (Array.isArray(res)) { for (let i = 0; i < res.length; i += 2) { if (res[i] && res[i+1]) votes[res[i].toUpperCase().trim()] = res[i+1].toUpperCase().trim(); } }
|
|
else if (res && typeof res === 'object') { for (const [k, v] of Object.entries(res)) { if (k && v) votes[k.toUpperCase().trim()] = String(v).toUpperCase().trim(); } }
|
|
state.votes = votes;
|
|
render();
|
|
} catch (e) { console.error(e); }
|
|
}
|
|
|
|
function showNotify(msg, isErr) {
|
|
let el = document.getElementById('standaloneNotify');
|
|
if (!el) {
|
|
el = document.createElement('div');
|
|
el.id = 'standaloneNotify';
|
|
el.style.cssText = 'position:fixed;bottom:20px;right:20px;padding:12px 18px;border-radius:12px;font-size:13px;font-weight:600;z-index:9999;transition:all 0.3s;box-shadow:0 10px 30px rgba(0,0,0,0.8);backdrop-filter:blur(10px);';
|
|
document.body.appendChild(el);
|
|
}
|
|
el.style.background = isErr ? 'rgba(239,68,68,0.9)' : 'rgba(0,183,212,0.95)';
|
|
el.style.color = isErr ? '#fff' : '#000';
|
|
el.innerText = msg;
|
|
el.style.display = 'block';
|
|
setTimeout(() => { if (el) el.style.display = 'none'; }, 4000);
|
|
}
|
|
|
|
async function submitVote() {
|
|
const voter = state.currentVoter.trim().toUpperCase();
|
|
const cand = state.selected || state.votes[voter];
|
|
if (!voter || voter.length < 2) return showNotify("Escribe tu nombre antes de votar.", true);
|
|
if (!cand) return showNotify("Selecciona un candidato.", true);
|
|
if (voter === cand || voter.split(/\\s+/).includes(cand)) return showNotify("No puedes votar por ti mismo.", true);
|
|
try {
|
|
await callRedis(["HSET", CONFIG.key, voter, cand]);
|
|
state.votes[voter] = cand;
|
|
localStorage.setItem('e3_voter_app', voter);
|
|
render();
|
|
showNotify("¡Voto registrado en la nube con éxito!", false);
|
|
} catch (e) { showNotify("Error: " + e.message, true); }
|
|
}
|
|
|
|
function render() {
|
|
const voter = state.currentVoter.trim().toUpperCase();
|
|
const pillsWrap = document.getElementById('pillsWrap');
|
|
pillsWrap.innerHTML = '';
|
|
const existingVote = state.votes[voter];
|
|
const activeCand = state.selected || existingVote;
|
|
|
|
CANDIDATOS.forEach((c, idx) => {
|
|
const isSelf = voter && (voter === c || voter.split(/\\s+/).includes(c));
|
|
const isSelected = activeCand === c;
|
|
const btn = document.createElement('button');
|
|
btn.className = 'btn-pill' + (isSelected ? ' active' : '');
|
|
btn.disabled = isSelf;
|
|
if (idx === CANDIDATOS.length - 1) btn.style.gridColumn = 'span 2';
|
|
btn.innerHTML = c + (isSelf ? ' <small style="font-size:9px;">(Tú)</small>' : '');
|
|
btn.onclick = () => { state.selected = c; render(); };
|
|
pillsWrap.appendChild(btn);
|
|
});
|
|
|
|
const counts = {}; CANDIDATOS.forEach(c => counts[c] = 0);
|
|
let total = 0;
|
|
Object.values(state.votes).forEach(c => { if (counts[c] !== undefined) { counts[c]++; total++; } });
|
|
document.getElementById('totalVotosTxt').innerText = 'TOTAL DE VOTOS: ' + total;
|
|
document.getElementById('pctTxt').innerText = (total > 0 ? Math.min(100, Math.round((total / Math.max(total, 12)) * 100)) : 0) + '%';
|
|
|
|
const sorted = CANDIDATOS.map(name => ({ name, votes: counts[name], pct: total > 0 ? (counts[name] / total) * 100 : 0 })).sort((a,b) => b.votes - a.votes);
|
|
const maxVotes = sorted[0]?.votes || 0;
|
|
const barsWrap = document.getElementById('barsWrap');
|
|
barsWrap.innerHTML = '';
|
|
|
|
sorted.forEach(item => {
|
|
const isLeader = maxVotes > 0 && item.votes === maxVotes;
|
|
const row = document.createElement('div');
|
|
row.className = 'bar-row';
|
|
row.innerHTML = \`<span style="font-size:12px;font-weight:600;color:\${isLeader?'#fff':'#aaa'};">\${item.name}</span><div class="bar-track"><div class="bar-fill \${isLeader?'leader':''}" style="width:\${Math.max(item.pct, item.votes>0?4:0)}%;"></div></div><span style="font-size:12px;font-family:monospace;color:\${isLeader?'var(--accent)':'#aaa'};">\${item.votes} \${isLeader?'<span class="badge">LÍDER</span>':''}</span>\`;
|
|
barsWrap.appendChild(row);
|
|
});
|
|
}
|
|
|
|
document.getElementById('voterInput').addEventListener('input', e => { state.currentVoter = e.target.value.toUpperCase(); localStorage.setItem('e3_voter_app', state.currentVoter); render(); });
|
|
document.getElementById('btnCastVote').addEventListener('click', submitVote);
|
|
setInterval(syncVotes, 2500);
|
|
syncVotes();
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
};
|
|
|
|
const handleCopySingleHtml = async () => {
|
|
try {
|
|
const html = generateStandaloneHtml();
|
|
await navigator.clipboard.writeText(html);
|
|
setCopiedSingleHtml(true);
|
|
setTimeout(() => setCopiedSingleHtml(false), 2500);
|
|
} catch {
|
|
// fallback
|
|
}
|
|
};
|
|
|
|
const handleDownloadSingleHtml = () => {
|
|
const html = generateStandaloneHtml();
|
|
const blob = new Blob([html], { type: 'text/html;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = 'encuesta-e3-immersive.html';
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="w-full mt-6">
|
|
<div className="glass-panel p-6 border border-white/10 shadow-2xl relative animate-in fade-in zoom-in-95 duration-200">
|
|
{onClose && (
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="absolute top-5 right-5 text-gray-400 hover:text-white p-1 cursor-pointer"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="w-8 h-8 rounded-lg bg-[#00B7D4]/10 border border-[#00B7D4]/30 flex items-center justify-center text-[#00B7D4]">
|
|
<KeyRound className="w-4 h-4" />
|
|
</div>
|
|
<div>
|
|
<h3 className="title-font text-lg text-white">Panel de Administración</h3>
|
|
<p className="text-[11px] text-gray-400 font-mono">
|
|
Operaciones Atómicas PostgreSQL & Exportación
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reset section */}
|
|
<div className="space-y-3 pb-5 border-b border-white/5">
|
|
<div>
|
|
<label className="block text-xs uppercase tracking-wider text-gray-400 font-semibold">
|
|
Remover Voto Individual o de Pruebas (DELETE)
|
|
</label>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-12 gap-3">
|
|
<div className="sm:col-span-5">
|
|
<select
|
|
value={selectedVoter}
|
|
onChange={(e) => setSelectedVoter(e.target.value)}
|
|
className="w-full px-3 py-2.5 bg-[#0f1318] border border-white/10 rounded-xl text-white text-xs focus:border-[#00B7D4] focus:outline-none uppercase font-mono cursor-pointer"
|
|
>
|
|
<option value="" className="bg-[#0f1318] text-gray-400 py-1">
|
|
Selecciona votante a remover...
|
|
</option>
|
|
{registeredVoters.map((v) => (
|
|
<option key={v} value={v} className="bg-[#0f1318] text-white py-1 font-semibold">
|
|
{v} → {rawVotes[v]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="sm:col-span-4">
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="Contraseña"
|
|
className="w-full px-3 py-2.5 bg-white/[0.04] border border-white/10 rounded-xl text-white text-xs focus:border-[#00B7D4] focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="sm:col-span-3 flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleResetVote}
|
|
disabled={isDeleting || !selectedVoter}
|
|
className="flex-1 px-3 py-2.5 bg-red-950/40 hover:bg-red-900/60 border border-red-500/40 text-red-300 rounded-xl text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer disabled:opacity-30 flex items-center justify-center gap-1.5"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
<span>{isDeleting ? '...' : 'Remover'}</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handlePromptResetAll}
|
|
disabled={isDeleting || registeredVoters.length === 0}
|
|
title="Vaciar todos los votos registrados para pruebas"
|
|
className="px-3 py-2.5 bg-amber-950/40 hover:bg-amber-900/60 border border-amber-500/40 text-amber-300 rounded-xl text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer disabled:opacity-30 flex items-center justify-center gap-1"
|
|
>
|
|
<span>Vaciar Todo</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{adminMsg && (
|
|
<div
|
|
className={`p-3 rounded-xl text-xs flex items-center gap-2 ${
|
|
adminMsg.type === 'success'
|
|
? 'bg-[#00e676]/10 text-[#00e676] border border-[#00e676]/20'
|
|
: 'bg-red-950/40 text-red-300 border border-red-500/30'
|
|
}`}
|
|
>
|
|
{adminMsg.type === 'success' ? (
|
|
<ShieldCheck className="w-4 h-4 shrink-0" />
|
|
) : (
|
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
|
)}
|
|
<span>{adminMsg.text}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Export section */}
|
|
<div className="pt-4 space-y-3">
|
|
<div className="text-xs uppercase tracking-wider text-gray-300 font-semibold">
|
|
Exportar Versión Autónoma Single-File (HTML5 + CSS + JS)
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2.5">
|
|
<button
|
|
type="button"
|
|
onClick={handleCopySingleHtml}
|
|
className="px-3.5 py-2 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/10 text-xs font-mono text-[#00B7D4] transition-all flex items-center gap-1.5 cursor-pointer"
|
|
>
|
|
{copiedSingleHtml ? <Check className="w-3.5 h-3.5 text-[#00e676]" /> : <Copy className="w-3.5 h-3.5" />}
|
|
<span>{copiedSingleHtml ? 'HTML Copiado' : 'Copiar Archivo HTML Autónomo'}</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleDownloadSingleHtml}
|
|
className="px-3.5 py-2 rounded-lg bg-[#00B7D4]/10 hover:bg-[#00B7D4]/20 border border-[#00B7D4] text-xs font-mono text-[#00B7D4] transition-all flex items-center gap-1.5 cursor-pointer"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
<span>Descargar .html</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleCopyCode}
|
|
className="px-3.5 py-2 rounded-lg bg-white/[0.02] hover:bg-white/[0.06] border border-white/10 text-xs font-mono text-gray-300 transition-all flex items-center gap-1.5 cursor-pointer"
|
|
>
|
|
{copiedCode ? <Check className="w-3.5 h-3.5 text-[#00e676]" /> : <Copy className="w-3.5 h-3.5" />}
|
|
<span>{copiedCode ? 'Código Copiado' : 'Copiar const VOTOS_EMBEBIDOS'}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal Interno de Confirmación */}
|
|
<ConfirmModal
|
|
isOpen={showConfirmResetAll}
|
|
title="¿Vaciar Todos los Votos?"
|
|
message="Esta acción eliminará todos los votos registrados en PostgreSQL para reiniciar las pruebas. No se puede deshacer."
|
|
confirmLabel="Sí, Vaciar Todo"
|
|
cancelLabel="Cancelar"
|
|
isDestructive={true}
|
|
onConfirm={handleExecuteResetAll}
|
|
onCancel={() => setShowConfirmResetAll(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|