feat: Separar paginas generador y qr en diferentes proyectos

This commit is contained in:
2026-07-17 15:14:32 -04:00
parent c736e43730
commit 85ffe635cd
23 changed files with 1349 additions and 429 deletions
+4
View File
@@ -0,0 +1,4 @@
VITE_SUPABASE_ANON_KEY=J5JS7HG...
VITE_SUPABASE_URL=https://tudominio
VITE_WEBHOOK_TOKEN=6d4g56d4fgd...
VITE_N8N_WEBHOOK_URL=https://tudominio/webhook/carnet
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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>glm-card-generator</title>
<script type="module" crossorigin src="/empleado-id/assets/index-Ef45i7N1.js"></script>
<link rel="stylesheet" crossorigin href="/empleado-id/assets/index-OsvWWXE6.css">
<script type="module" crossorigin src="/empleado-id/assets/index-hkdwba_S.js"></script>
<link rel="stylesheet" crossorigin href="/empleado-id/assets/index-DZLLXZoS.css">
</head>
<body>
<div id="root"></div>
-11
View File
@@ -2,7 +2,6 @@ import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-route
import { supabase } from "./services/supabase";
import { useEffect, useState, createContext, useContext } from "react";
import Login from "./components/Login";
import EmployeeCard from "./components/EmployeeCard";
import IdCardGenerator from "./Generator";
import "./components/Login.css";
@@ -157,16 +156,6 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/empleado-id/:employeeNumber"
element={
<ProtectedRoute>
<EmployeeCard />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/empleado-id/" replace />} />
</Routes>
</BrowserRouter>
+235 -214
View File
@@ -1,8 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import './Generator.css';
import { renderQRCode, downloadIDCards, downloadBulkIDCards, getXLSXLib, clientTemplates } from './script/script.jsx';
import { uploadEmployeePhoto, saveClienteProyecto } from './services/storage.js';
import { supabase } from "./services/supabase";
import { saveClienteProyecto } from './services/storage.js';
// Convierte datos binarios recibidos (base64, buffer, array de bytes) en un Blob URL local de forma robusta
const convertBinaryToBlobUrl = (data) => {
@@ -175,17 +174,6 @@ export default function IdCardGenerator() {
// 1) Guardar cédula + cliente_proyecto en carnet_empleados_creados_glm
await saveClienteProyecto(cleanCedula, clienteProyectoValue);
// 2) Subir foto al storage
const customFile = new File([blobData], `${cleanCedula}.jpg`, { type: 'image/jpeg' });
const remoteStorageUrl = await uploadEmployeePhoto(cleanCedula, customFile);
// 3) Guardar URL de foto
if (remoteStorageUrl) {
await supabase.rpc("save_employee_photo", {
p_employee_number: cleanCedula,
p_photo_url: remoteStorageUrl
});
}
} catch (error) {
// silent
}
@@ -317,16 +305,43 @@ export default function IdCardGenerator() {
const triggerDownload = async () => {
if (!validateForm()) return;
setDownloadStatus({ processing: true, text: '🔄 Descargando...' });
try {
await saveClienteProyecto(employeeId, selectedClient);
if (photoFile) {
const photoUrl = await uploadEmployeePhoto(employeeId, photoFile);
await supabase.rpc("save_employee_photo", {
p_employee_number: employeeId,
p_photo_url: photoUrl
});
setDownloadStatus({ processing: true, text: '🔄 Descargando...' });
try {
// Enviar binario (base64) + employeeId como JSON al webhook n8n
const N8N_WEBHOOK_URL = import.meta.env.VITE_N8N_WEBHOOK_URL;
const TOKEN_SECRETO = import.meta.env.VITE_WEBHOOK_TOKEN;
let photoBase64 = null;
if (photoSrc && photoSrc.startsWith('blob:')) {
const response = await fetch(photoSrc);
if (response.ok) {
const blob = await response.blob();
photoBase64 = await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result.split(',')[1]);
reader.readAsDataURL(blob);
});
}
}
const payload = {
data: photoBase64,
employeeId: employeeId
};
const response = await fetch(N8N_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': TOKEN_SECRETO
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`El servidor de n8n rechazó la petición (Código: ${response.status})`);
}
await downloadIDCards({
@@ -335,6 +350,12 @@ export default function IdCardGenerator() {
qrCanvas: qrCanvasRef.current,
onStateChange: setDownloadStatus
});
// Guardar cédula + cliente_proyecto en carnet_empleados_creados_glm al completar descarga
const cleanCedula = String(employeeId || '').replace(/[-\s]/g, '');
if (cleanCedula) {
await saveClienteProyecto(cleanCedula, selectedClient);
}
} catch (err) {
alert(err.message);
setDownloadStatus({ processing: false, text: '⬇ Descargar' });
@@ -342,214 +363,214 @@ export default function IdCardGenerator() {
};
const validateForm = () => {
if (!name.trim()) { alert("Debe ingresar el nombre."); return false; }
if (!role.trim()) { alert("Debe ingresar el puesto."); return false; }
if (!employeeId.trim()) { alert("Debe ingresar el ID del empleado."); return false; }
if (!photoFile) { alert("Debe subir la foto del colaborador."); return false; }
return true;
};
if (!name.trim()) { alert("Debe ingresar el nombre."); return false; }
if (!role.trim()) { alert("Debe ingresar el puesto."); return false; }
if (!employeeId.trim()) { alert("Debe ingresar el ID del empleado."); return false; }
if (!photoFile) { alert("Debe subir la foto del colaborador."); return false; }
return true;
};
return (
<>
<div className="page-title">
<h1>GLM ID Card Generator</h1>
<p>GomezLee Marketing · Carnet Corporativo CR80</p>
</div>
return (
<>
<div className="page-title">
<h1>GLM ID Card Generator</h1>
<p>GomezLee Marketing · Carnet Corporativo CR80</p>
</div>
<div className="main-container" style={{ display: 'flex', gap: '20px', alignItems: 'flex-start' }}>
{/* FORMULARIO MASIVO EXCEL */}
<div className="panel" style={{ minWidth: '320px' }}>
<div className="preview-badge" style={{ background: '#FFF3E0', color: '#E65100' }}>
<div className="dot" style={{ background: '#E65100' }}></div>
<span>Módulo de Lotes Automáticos</span>
</div>
<div className="main-container" style={{ display: 'flex', gap: '20px', alignItems: 'flex-start' }}>
{/* FORMULARIO MASIVO EXCEL */}
<div className="panel" style={{ minWidth: '320px' }}>
<div className="preview-badge" style={{ background: '#FFF3E0', color: '#E65100' }}>
<div className="dot" style={{ background: '#E65100' }}></div>
<span>Módulo de Lotes Automáticos</span>
</div>
<div className="panel-section">
<div className="panel-section-title" style={{ display: 'flex', alignItems: 'center', gap: '6px', position: 'relative' }}>
<span>Carga Masiva de Colaboradores</span>
<div
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: '16px', height: '16px', borderRadius: '50%',
background: '#cbd5e1', color: '#334155', fontSize: '11px',
fontWeight: 'bold', cursor: 'help', userSelect: 'none'
}}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
?
<div className="panel-section">
<div className="panel-section-title" style={{ display: 'flex', alignItems: 'center', gap: '6px', position: 'relative' }}>
<span>Carga Masiva de Colaboradores</span>
<div
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: '16px', height: '16px', borderRadius: '50%',
background: '#cbd5e1', color: '#334155', fontSize: '11px',
fontWeight: 'bold', cursor: 'help', userSelect: 'none'
}}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
?
</div>
{showTooltip && (
<div style={{
position: 'absolute', top: '24px', left: '0', right: '0',
background: '#1e293b', color: '#ffffff', padding: '10px',
borderRadius: '6px', fontSize: '11px', lineHeight: '1.4',
zIndex: '99', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
fontWeight: 'normal', textTransform: 'none'
}}>
Formato de columnas requeridas en el Excel: <br />
<b style={{ color: '#6CC24A' }}>nombre, puesto, cliente/proyecto, cedula, lenguaje, foto_url</b>.<br />
</div>
)}
</div>
{showTooltip && (
<div style={{
position: 'absolute', top: '24px', left: '0', right: '0',
background: '#1e293b', color: '#ffffff', padding: '10px',
borderRadius: '6px', fontSize: '11px', lineHeight: '1.4',
zIndex: '99', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
fontWeight: 'normal', textTransform: 'none'
}}>
Formato de columnas requeridas en el Excel: <br />
<b style={{ color: '#6CC24A' }}>nombre, puesto, cliente/proyecto, cedula, lenguaje, foto_url</b>.<br />
<div className="upload-btn" style={{ background: '#475569', marginTop: '12px' }} onClick={() => excelInputRef.current.click()}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
Seleccionar Archivo Excel
</div>
<input type="file" accept=".xlsx, .xls, .csv" style={{ display: 'none' }} ref={excelInputRef} onChange={handleExcelUpload} />
{bulkStatusText && (
<div style={{ fontSize: '12px', marginTop: '8px', fontWeight: '500', color: '#475569' }}>
{bulkStatusText}
</div>
)}
</div>
<div className="upload-btn" style={{ background: '#475569', marginTop: '12px' }} onClick={() => excelInputRef.current.click()}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
Seleccionar Archivo Excel
</div>
<input type="file" accept=".xlsx, .xls, .csv" style={{ display: 'none' }} ref={excelInputRef} onChange={handleExcelUpload} />
<div className="divider"></div>
{bulkStatusText && (
<div style={{ fontSize: '12px', marginTop: '8px', fontWeight: '500', color: '#475569' }}>
{bulkStatusText}
<div className="panel-section">
<div className="panel-section-title">Exportar Lote</div>
<button
className="export-btn blue"
disabled={bulkDownloadStatus.processing || bulkEmployees.length === 0}
onClick={triggerBulkDownload}
style={{ opacity: bulkEmployees.length === 0 ? 0.6 : 1 }}
>
{bulkDownloadStatus.text}
</button>
</div>
</div>
{/* FORMULARIO INDIVIDUAL */}
<div className="panel">
<div className="preview-badge">
<div className="dot"></div>
<span>Vista previa en tiempo real</span>
</div>
<div className="panel-section">
<div className="panel-section-title">Colaborador</div>
<div className="field">
<label>Nombre</label>
<input type="text" placeholder="Ej: Alexi Zabala" value={name} onChange={(e) => setName(e.target.value)} />
</div>
)}
</div>
<div className="divider"></div>
<div className="panel-section">
<div className="panel-section-title">Exportar Lote</div>
<button
className="export-btn blue"
disabled={bulkDownloadStatus.processing || bulkEmployees.length === 0}
onClick={triggerBulkDownload}
style={{ opacity: bulkEmployees.length === 0 ? 0.6 : 1 }}
>
{bulkDownloadStatus.text}
</button>
</div>
</div>
{/* FORMULARIO INDIVIDUAL */}
<div className="panel">
<div className="preview-badge">
<div className="dot"></div>
<span>Vista previa en tiempo real</span>
</div>
<div className="panel-section">
<div className="panel-section-title">Colaborador</div>
<div className="field">
<label>Nombre</label>
<input type="text" placeholder="Ej: Alexi Zabala" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="field">
<label>Puesto</label>
<input type="text" placeholder="Ej: Mercaderista" value={role} onChange={(e) => setRole(e.target.value)} />
</div>
<div className="upload-btn" onClick={() => !photoProcessing && fileInputRef.current.click()} style={{ opacity: photoProcessing ? 0.6 : 1, cursor: photoProcessing ? 'wait' : 'pointer' }}>
{photoProcessing ? '⏳ Procesando foto con IA...' : 'Subir foto del colaborador'}
</div>
<input type="file" accept="image/*" style={{ display: 'none' }} ref={fileInputRef} onChange={handlePhotoUpload} />
</div>
<div className="divider"></div>
<div className="panel-section">
<div className="panel-section-title">Cliente / Proyecto</div>
<div className="lang-container">
<button type="button" className={`lang-btn ${language === 'Esp' ? 'active' : ''}`} onClick={() => setLanguage('Esp')}>Español</button>
<button type="button" className={`lang-btn ${language === 'Ing' ? 'active' : ''}`} onClick={() => setLanguage('Ing')}>Inglés</button>
</div>
<div className="field">
<label>Seleccionar Cliente</label>
<select value={selectedClient} onChange={(e) => setSelectedClient(e.target.value)}>
<option value="Generico">Genérico (Por defecto)</option>
<option value="Claro">Claro</option>
<option value="Colgate">Colgate</option>
<option value="KitchenAid">KitchenAid</option>
<option value="Kraft">Kraft</option>
<option value="Motorola">Motorola</option>
<option value="Nestle">Nestle</option>
<option value="P&G">P&G</option>
<option value="Philip Morris">Philip Morris International</option>
<option value="Whirlpool">Whirlpool</option>
</select>
</div>
</div>
<div className="divider"></div>
<div className="panel-section">
<div className="panel-section-title">Código QR</div>
<div className="field">
<label>ID del empleado / Cédula</label>
<input
type="text"
placeholder="Ej: 123456789 (Sin guiones ni espacios)"
value={employeeId}
onChange={handleEmployeeIdChange}
/>
</div>
<button className="export-btn" style={{ background: '#6CC24A', marginTop: '2px' }} onClick={() => renderQRCode(employeeId, qrCanvasRef.current)}>
Generar QR
</button>
</div>
<div className="divider"></div>
<div className="panel-section">
<div className="panel-section-title">Exportar</div>
<button className="export-btn blue" disabled={downloadStatus.processing || !isFormComplete} onClick={triggerDownload} style={{ opacity: (!isFormComplete || downloadStatus.processing) ? 0.6 : 1 }}>
{downloadStatus.text}
</button>
</div>
</div>
{/* VISTA PREVIA */}
<div className="stage">
<div className="card-wrap">
<div className="card-label">Frente</div>
<div className="card" id="cardFront" style={{ backgroundImage: `url('${baseUrl}images/AF GLM Frente.png')` }}>
<div className="front-photo-frame" style={{ position: 'relative', overflow: 'hidden' }}>
{photoSrc ? (
<img src={photoSrc} alt="Foto Colaborador" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : (null)}
{photoProcessing && (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.45)', color: '#fff',
fontSize: '13px', fontWeight: '600', textAlign: 'center', padding: '8px'
}}>
Editando foto con IA...
</div>
)}
{!photoSrc && (
<svg className="placeholder-svg" viewBox="0 0 28 28" fill="none" style={{ width: '60%', height: '60%', opacity: 0.4 }}>
<circle cx="14" cy="10" r="5.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M3 24c0-6 22-6 22 0" stroke="currentColor" strokeWidth="1.5" />
</svg>
)}
<div className="field">
<label>Puesto</label>
<input type="text" placeholder="Ej: Mercaderista" value={role} onChange={(e) => setRole(e.target.value)} />
</div>
<div className="front-dynamic-name">{name ? name.toUpperCase() : 'NOMBRE APELLIDO'}</div>
<div className="front-dynamic-role">{role ? role.toUpperCase() : 'PUESTO'}</div>
<div className="upload-btn" onClick={() => !photoProcessing && fileInputRef.current.click()} style={{ opacity: photoProcessing ? 0.6 : 1, cursor: photoProcessing ? 'wait' : 'pointer' }}>
{photoProcessing ? '⏳ Procesando foto con IA...' : 'Subir foto del colaborador'}
</div>
<input type="file" accept="image/*" style={{ display: 'none' }} ref={fileInputRef} onChange={handlePhotoUpload} />
</div>
<div className="divider"></div>
<div className="panel-section">
<div className="panel-section-title">Cliente / Proyecto</div>
<div className="lang-container">
<button type="button" className={`lang-btn ${language === 'Esp' ? 'active' : ''}`} onClick={() => setLanguage('Esp')}>Español</button>
<button type="button" className={`lang-btn ${language === 'Ing' ? 'active' : ''}`} onClick={() => setLanguage('Ing')}>Inglés</button>
</div>
<div className="field">
<label>Seleccionar Cliente</label>
<select value={selectedClient} onChange={(e) => setSelectedClient(e.target.value)}>
<option value="Generico">Genérico (Por defecto)</option>
<option value="Claro">Claro</option>
<option value="Colgate">Colgate</option>
<option value="KitchenAid">KitchenAid</option>
<option value="Kraft">Kraft</option>
<option value="Motorola">Motorola</option>
<option value="Nestle">Nestle</option>
<option value="P&G">P&G</option>
<option value="Philip Morris">Philip Morris International</option>
<option value="Whirlpool">Whirlpool</option>
</select>
</div>
</div>
<div className="divider"></div>
<div className="panel-section">
<div className="panel-section-title">Código QR</div>
<div className="field">
<label>ID del empleado / Cédula</label>
<input
type="text"
placeholder="Ej: 123456789 (Sin guiones ni espacios)"
value={employeeId}
onChange={handleEmployeeIdChange}
/>
</div>
<button className="export-btn" style={{ background: '#6CC24A', marginTop: '2px' }} onClick={() => renderQRCode(employeeId, qrCanvasRef.current)}>
Generar QR
</button>
</div>
<div className="divider"></div>
<div className="panel-section">
<div className="panel-section-title">Exportar</div>
<button className="export-btn blue" disabled={downloadStatus.processing || !isFormComplete} onClick={triggerDownload} style={{ opacity: (!isFormComplete || downloadStatus.processing) ? 0.6 : 1 }}>
{downloadStatus.text}
</button>
</div>
</div>
<div className="card-wrap">
<div className="card-label">Reverso</div>
<div
className="card"
id="cardBack"
style={{
backgroundImage: `url('${baseUrl}${(clientTemplates[language] || clientTemplates['Esp'])[selectedClient] || (clientTemplates[language] || clientTemplates['Esp'])['Generico']}')`
}}
>
<div className="back-qr-zone">
<canvas ref={qrCanvasRef} width={55} height={55}></canvas>
{/* VISTA PREVIA */}
<div className="stage">
<div className="card-wrap">
<div className="card-label">Frente</div>
<div className="card" id="cardFront" style={{ backgroundImage: `url('${baseUrl}images/AF GLM Frente.png')` }}>
<div className="front-photo-frame" style={{ position: 'relative', overflow: 'hidden' }}>
{photoSrc ? (
<img src={photoSrc} alt="Foto Colaborador" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : (null)}
{photoProcessing && (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.45)', color: '#fff',
fontSize: '13px', fontWeight: '600', textAlign: 'center', padding: '8px'
}}>
Editando foto con IA...
</div>
)}
{!photoSrc && (
<svg className="placeholder-svg" viewBox="0 0 28 28" fill="none" style={{ width: '60%', height: '60%', opacity: 0.4 }}>
<circle cx="14" cy="10" r="5.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M3 24c0-6 22-6 22 0" stroke="currentColor" strokeWidth="1.5" />
</svg>
)}
</div>
<div className="front-dynamic-name">{name ? name.toUpperCase() : 'NOMBRE APELLIDO'}</div>
<div className="front-dynamic-role">{role ? role.toUpperCase() : 'PUESTO'}</div>
</div>
</div>
<div className="card-wrap">
<div className="card-label">Reverso</div>
<div
className="card"
id="cardBack"
style={{
backgroundImage: `url('${baseUrl}${(clientTemplates[language] || clientTemplates['Esp'])[selectedClient] || (clientTemplates[language] || clientTemplates['Esp'])['Generico']}')`
}}
>
<div className="back-qr-zone">
<canvas ref={qrCanvasRef} width={55} height={55}></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
}
</>
);
}
-1
View File
@@ -1,6 +1,5 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
+1 -1
View File
@@ -131,7 +131,7 @@ export async function renderQRCode(employeeId, canvasElement) {
document.body.appendChild(tmp);
new QRCodeLib(tmp, {
text: `http://digitalcompass.agency/empleado?id=${cleanId}`,
text: `https://digitalcompass.agency/empleado?id=${cleanId}`,
width: 55,
height: 55,
colorDark: '#000000',