From 710045ee1119236d27a525210264efc147d23400 Mon Sep 17 00:00:00 2001 From: EidanThen Date: Tue, 30 Jun 2026 15:08:52 -0400 Subject: [PATCH] feat: implement bulk ID card generation via Excel upload and individual form processing with Supabase storage integration --- src/Generator.jsx | 406 ++++++++++++++++++++++-------------------- src/script/script.jsx | 226 ++++++++++++++++++----- 2 files changed, 392 insertions(+), 240 deletions(-) diff --git a/src/Generator.jsx b/src/Generator.jsx index bda92b0..ce28998 100644 --- a/src/Generator.jsx +++ b/src/Generator.jsx @@ -1,28 +1,35 @@ import React, { useState, useRef, useEffect } from 'react'; import './App.css'; -// Importamos las funciones lógicas nativas desde el archivo portado script.jsx -import { renderQRCode, downloadIDCards } from './script/script.jsx'; +import { renderQRCode, downloadIDCards, downloadBulkIDCards, getXLSXLib } from './script/script.jsx'; import { uploadEmployeePhoto } from './services/storage.js'; import { supabase } from "./services/supabase"; export default function IdCardGenerator() { - // --- Estados para manejar la lógica en tiempo real ─── + // --- Estados formulario individual ─── const [name, setName] = useState(''); const [role, setRole] = useState(''); - const [language, setLanguage] = useState('Esp'); // 'Esp' o 'Ing' - const [selectedClient, setSelectedClient] = useState('Generico'); // Sin tildes de forma interna + const [language, setLanguage] = useState('Esp'); + const [selectedClient, setSelectedClient] = useState('Generico'); const [employeeId, setEmployeeId] = useState(''); const [photoSrc, setPhotoSrc] = useState(''); const [photoFile, setPhotoFile] = useState(null); - - // Estado para controlar la UI del botón de descarga mientras procesa const [downloadStatus, setDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' }); - // Referencias del DOM controladas por React + // --- Estados Lote / Excel Masivo ─── + const [bulkEmployees, setBulkEmployees] = useState([]); + const [bulkStatusText, setBulkStatusText] = useState(''); + const [bulkDownloadStatus, setBulkDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' }); + const [showTooltip, setShowTooltip] = useState(false); + const fileInputRef = useRef(null); + const excelInputRef = useRef(null); const qrCanvasRef = useRef(null); - // EFECTO AUTOMÁTICO: Renderiza el QR en tiempo real mientras escribes mediante referencias + // Determinar la URL base de Vite de forma segura (Solo una declaración única aquí) + const baseUrl = import.meta.env.BASE_URL.endsWith('/') + ? import.meta.env.BASE_URL + : `${import.meta.env.BASE_URL}/`; + useEffect(() => { if (qrCanvasRef.current) { renderQRCode(employeeId, qrCanvasRef.current); @@ -31,112 +38,132 @@ export default function IdCardGenerator() { const handlePhotoUpload = (e) => { const file = e.target.files[0]; - if (file) { setPhotoFile(file); - - const localUrl = URL.createObjectURL(file); - setPhotoSrc(localUrl); + setPhotoSrc(URL.createObjectURL(file)); } }; - const handleLanguageChange = (lang) => { - setLanguage(lang); - }; - - const handleClientChange = (e) => { - setSelectedClient(e.target.value); - }; - - // Forzar generación del QR manualmente si se desea mediante el botón verde - const triggerGenerateQR = () => { - if (qrCanvasRef.current) { - renderQRCode(employeeId, qrCanvasRef.current); - } - }; - - // Disparador del motor de renderizado ULTRA MAX QUALITY en sandbox - const triggerDownload = async () => { - if (!validateForm()) { - return; - } + // --- MAPEO DE COLUMNAS CORREGIDO --- + const handleExcelUpload = async (e) => { + const file = e.target.files[0]; + if (!file) return; + setBulkStatusText('Leyendo documento...'); try { + const XLSXLib = await getXLSXLib(); + const reader = new FileReader(); - // Solo si el usuario seleccionó una foto - if (photoFile) { + reader.onload = (evt) => { + try { + const bstr = evt.target.result; + const wb = XLSXLib.read(bstr, { type: 'binary' }); + const wsname = wb.SheetNames[0]; + const ws = wb.Sheets[wsname]; + const jsonRows = XLSXLib.utils.sheet_to_json(ws); - const photoUrl = await uploadEmployeePhoto( - employeeId, - photoFile - ); - - console.log("Foto subida:", photoUrl); - - const { data, error } = await supabase.rpc( - "save_employee_photo", - { - p_employee_number: employeeId, - p_photo_url: photoUrl + if (jsonRows.length === 0) { + setBulkStatusText('❌ El archivo está vacío.'); + return; } - ); - if (error) { - throw error; - } + const formatted = jsonRows.map((row) => { + // 1. Normalizar Idioma (ES/Español -> Esp, EN/Inglés -> Ing) + let rawLang = String(row.lenguaje || 'Esp').trim().toLowerCase(); + if (rawLang.includes('es') || rawLang.includes('esp')) { + rawLang = 'Esp'; + } else if (rawLang.includes('en') || rawLang.includes('ing')) { + rawLang = 'Ing'; + } else { + rawLang = 'Esp'; + } - if (!data.success) { - console.warn(data.message); - } else { - console.log(data.message); + // 2. CORRECCIÓN: Leer la columna exacta "cliente/proyecto" por separado + let rawClient = String(row['cliente/proyecto'] || 'Generico') + .trim() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, ""); // Convierte "Nestlé" en "Nestle" + + // Mapear nombres específicos para asegurar coincidencia exacta con las llaves de script.jsx + const lowerClient = rawClient.toLowerCase(); + if (lowerClient.includes('nestle')) rawClient = 'Nestle'; + else if (lowerClient.includes('claro')) rawClient = 'Claro'; + else if (lowerClient.includes('colgate')) rawClient = 'Colgate'; + else if (lowerClient.includes('kitchenaid') || lowerClient.includes('kitchen')) rawClient = 'KitchenAid'; + else if (lowerClient.includes('kraft')) rawClient = 'Kraft'; + else if (lowerClient.includes('motorola')) rawClient = 'Motorola'; + else if (lowerClient.includes('p&g') || lowerClient.includes('p y g')) rawClient = 'P&G'; + else if (lowerClient.includes('philip') || lowerClient.includes('morris')) rawClient = 'Philip Morris'; + else if (lowerClient.includes('whirlpool')) rawClient = 'Whirlpool'; + else rawClient = 'Generico'; + + return { + name: String(row.nombre || '').trim(), + role: String(row.puesto || '').trim(), + selectedClient: rawClient, + language: rawLang, + employeeId: String(row.cedula || '').trim(), // CORRECCIÓN: Columna individual 'cedula' + fotoUrl: String(row.foto_url || '').trim() + }; + }).filter(emp => emp.name && emp.role); + + setBulkEmployees(formatted); + setBulkStatusText(`✅ ¡Cargados ${formatted.length} colaboradores con éxito!`); + } catch (innerErr) { + console.error(innerErr); + setBulkStatusText('❌ Error de lectura. Revisa el formato de columnas.'); } + }; + reader.readAsBinaryString(file); + } catch (err) { + console.error(err); + setBulkStatusText('❌ Error cargando el motor XLSX.'); + } + }; + + const triggerBulkDownload = async () => { + if (bulkEmployees.length === 0) return; + try { + await downloadBulkIDCards({ + employees: bulkEmployees, + baseUrl, + onStateChange: setBulkDownloadStatus + }); + setBulkStatusText('🎉 ¡Lote procesado y guardado!'); + } catch (error) { + console.error(error); + alert("Error procesando las imágenes remotas."); + } + }; + + const triggerDownload = async () => { + if (!validateForm()) return; + try { + if (photoFile) { + const photoUrl = await uploadEmployeePhoto(employeeId, photoFile); + await supabase.rpc("save_employee_photo", { + p_employee_number: employeeId, + p_photo_url: photoUrl + }); } await downloadIDCards({ - name, - role, - selectedClient, - language, - photoSrc, - baseUrl: import.meta.env.BASE_URL.endsWith('/') - ? import.meta.env.BASE_URL - : `${import.meta.env.BASE_URL}/`, + name, role, selectedClient, language, photoSrc, + baseUrl, qrCanvas: qrCanvasRef.current, onStateChange: setDownloadStatus }); - } catch (err) { console.error(err); alert(err.message); } - }; - const baseUrl = import.meta.env.BASE_URL.endsWith('/') - ? import.meta.env.BASE_URL - : `${import.meta.env.BASE_URL}/`; - 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; - } - + 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; }; @@ -147,83 +174,113 @@ export default function IdCardGenerator() {

GomezLee Marketing · Carnet Corporativo CR80

-
-
+
+ {/* FORMULARIO MASIVO EXCEL (IZQUIERDA) */} +
+
+
+ Módulo de Lotes Automáticos +
+ +
+
+ Carga Masiva de Colaboradores +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + ? +
+ + {showTooltip && ( +
+ Formato de columnas requeridas en el Excel:
+ nombre, puesto, cliente/proyecto, cedula, lenguaje, foto_url.
+ Las imágenes se descargarán automáticamente desde los enlaces públicos de Google Drive. +
+ )} +
+ +
excelInputRef.current.click()}> + + + + + + + Seleccionar Archivo Excel +
+ + + {bulkStatusText && ( +
+ {bulkStatusText} +
+ )} +
+ +
+ +
+
Exportar Lote
+ +
+
+ + {/* FORMULARIO INDIVIDUAL (CENTRO) */} +
Vista previa en tiempo real
- {/* Sección Colaborador */}
Colaborador
-
- setName(e.target.value)} - /> + setName(e.target.value)} />
-
- setRole(e.target.value)} - /> + setRole(e.target.value)} />
-
fileInputRef.current.click()}> - - - - Subir foto del colaborador
- +
- {/* Sección Cliente / Proyecto */}
Cliente / Proyecto
-
- - + +
-
- setSelectedClient(e.target.value)}> @@ -240,104 +297,57 @@ export default function IdCardGenerator() {
- {/* Sección Código QR */}
Código QR
- setEmployeeId(e.target.value)} - /> + setEmployeeId(e.target.value)} />
-
- {/* Sección Exportar */}
Exportar
-
-
- {/* --- STAGE: Vista previa de las tarjetas ─── */} + {/* VISTA PREVIA (DERECHA) */}
- - {/* Frente */}
Frente
-
-
+
+
{photoSrc ? ( - Foto Colaborador + Foto Colaborador ) : ( - + )}
- -
- {name ? name.toUpperCase() : 'NOMBRE APELLIDO'} -
-
- {role ? role.toUpperCase() : 'PUESTO'} -
+
{name ? name.toUpperCase() : 'NOMBRE APELLIDO'}
+
{role ? role.toUpperCase() : 'PUESTO'}
- {/* Reverso */}
Reverso
-
+
- {/* Asignamos la referencia de React para un control de pixeles seguro */}
-
+
); diff --git a/src/script/script.jsx b/src/script/script.jsx index 5bed857..8310fc0 100644 --- a/src/script/script.jsx +++ b/src/script/script.jsx @@ -3,7 +3,7 @@ export const clientTemplates = { 'Esp': { 'Generico': 'images/AF Generico Esp.png', 'Claro': 'images/AF Claro Esp.png', - 'Colgate': 'images/GLM Colgate Esp.png', + 'Colgate': 'images/AF Colgate Esp.png', 'KitchenAid': 'images/AF KitchenAid Esp.png', 'Kraft': 'images/AF Kraft Esp.png', 'Motorola': 'images/AF Motorola Esp.png', @@ -15,7 +15,7 @@ export const clientTemplates = { 'Ing': { 'Generico': 'images/AF Generico Ing.png', 'Claro': 'images/AF Claro Ing.png', - 'Colgate': 'images/GLM Colgate Ing.png', + 'Colgate': 'images/AF Colgate Ing.png', 'KitchenAid': 'images/AF KitchenAid Ing.png', 'Kraft': 'images/AF Kraft Ing.png', 'Motorola': 'images/AF Motorola Ing.png', @@ -37,10 +37,33 @@ function loadScript(src, globalKey) { }); } -const getQRLib = () => loadScript('https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js', 'QRCode'); +export const getQRLib = () => loadScript('https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js', 'QRCode'); const getHtml2Canvas = () => loadScript('https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js', 'html2canvas'); +export const getJSZip = () => loadScript('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js', 'JSZip'); +export const getXLSXLib = () => loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js', 'XLSX'); -// ── GENERADOR DE QR NATIVO (REACT RENDERING) ── +function convertDriveUrlToDirect(url) { + if (!url) return ''; + const regExp = /(?:https?:\/\/)?(?:drive\.google\.com\/)(?:file\/d\/|open\?id=)([\w-]+)/; + const match = url.match(regExp); + if (match && match[1]) { + return `https://docs.google.com/uc?export=download&id=${match[1]}`; + } + return url; +} + +function preloadImage(src) { + return new Promise((resolve) => { + if (!src) return resolve(null); + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(src); + img.onerror = () => resolve(null); + img.src = src; + }); +} + +// ── GENERADOR DE QR NATIVO ── export async function renderQRCode(employeeId, canvasElement) { if (!canvasElement) return; const text = (employeeId || '').trim(); @@ -63,32 +86,42 @@ export async function renderQRCode(employeeId, canvasElement) { correctLevel: QRCodeLib.CorrectLevel.M }); - setTimeout(() => { - const img = tmp.querySelector('img') || tmp.querySelector('canvas'); + await new Promise((resolve) => setTimeout(resolve, 60)); - const drawIt = (src) => { + const img = tmp.querySelector('img') || tmp.querySelector('canvas'); + + const drawIt = (src) => { + return new Promise((res) => { const i = new Image(); i.onload = () => { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, 55, 55); ctx.drawImage(i, 0, 0, 55, 55); if (tmp.parentNode) document.body.removeChild(tmp); + res(); }; i.src = src; - }; + }); + }; - if (img && img.tagName === 'CANVAS') { - drawIt(img.toDataURL()); - } else if (img) { - if (img.complete) drawIt(img.src); - else img.onload = () => drawIt(img.src); - } else { - if (tmp.parentNode) document.body.removeChild(tmp); + if (img && img.tagName === 'CANVAS') { + await drawIt(img.toDataURL()); + } else if (img) { + if (img.complete) await drawIt(img.src); + else { + await new Promise((res) => { + img.onload = async () => { + await drawIt(img.src); + res(); + }; + }); } - }, 50); + } else { + if (tmp.parentNode) document.body.removeChild(tmp); + } } -// ── ENGINE DE PROCESAMIENTO ULTRA MAX QUALITY (650x1004) ── +// ── ENGINE DE PROCESAMIENTO INDIVIDUAL ── export async function downloadIDCards(config) { const { name, role, selectedClient, language, photoSrc, baseUrl, qrCanvas, onStateChange } = config; @@ -97,13 +130,11 @@ export async function downloadIDCards(config) { const html2canvasLib = await getHtml2Canvas(); const cleanName = name.trim().replace(/\s+/g, '') || 'Empleado'; - // Resolver rutas absolutas const frontBgUrl = `${baseUrl}images/AF GLM Frente.png`; const currentGroup = clientTemplates[language] || clientTemplates['Esp']; const backBgFile = currentGroup[selectedClient] || 'images/AF Generico Esp.png'; const backBgUrl = `${baseUrl}${backBgFile}`; - // Sandbox virtual aislado const sandbox = document.createElement('div'); sandbox.style.position = 'fixed'; sandbox.style.top = '-9999px'; @@ -125,7 +156,6 @@ export async function downloadIDCards(config) { font-family: Arial, Helvetica, sans-serif; `; - // 1. Frente Virtual en tamaño real const vFront = document.createElement('div'); vFront.style.cssText = cardStyleBase; vFront.style.backgroundImage = `url('${frontBgUrl}')`; @@ -155,24 +185,12 @@ export async function downloadIDCards(config) {
`; - // 2. Reverso Virtual en tamaño real const vBack = document.createElement('div'); vBack.style.cssText = cardStyleBase; vBack.style.backgroundImage = `url('${backBgUrl}')`; const vQRZone = document.createElement('div'); - vQRZone.style.cssText = ` - position: absolute; - bottom: 27px; - right: 27px; - background: #ffffff; - padding: 11px; - border-radius: 16px; - display: flex; - align-items: center; - justify-content: center; - z-index: 5; - `; + vQRZone.style.cssText = `position: absolute; bottom: 27px; right: 27px; background: #ffffff; padding: 11px; border-radius: 16px; display: flex; align-items: center; justify-content: center; z-index: 5;`; const vQRCanvas = document.createElement('canvas'); vQRCanvas.width = 149; @@ -191,15 +209,7 @@ export async function downloadIDCards(config) { sandbox.appendChild(vFront); sandbox.appendChild(vBack); - const renderOpts = { - scale: 1, - useCORS: true, - allowTaint: false, - backgroundColor: null, - logging: false, - width: 650, - height: 1004 - }; + const renderOpts = { scale: 1, useCORS: true, allowTaint: false, backgroundColor: null, logging: false, width: 650, height: 1004 }; try { const frontCanvas = await html2canvasLib(vFront, renderOpts); @@ -215,6 +225,138 @@ export async function downloadIDCards(config) { } } +// ── ENGINE MASIVO CON CORRESPONDENCIA DINÁMICA DE REVERSO POR IDIOMA ── +export async function downloadBulkIDCards({ employees, baseUrl, onStateChange }) { + if (onStateChange) onStateChange({ processing: true, text: 'Descargando...' }); + + const html2canvasLib = await getHtml2Canvas(); + const JSZipLib = await getJSZip(); + const zip = new JSZipLib(); + + const hiddenQRCanvas = document.createElement('canvas'); + hiddenQRCanvas.width = 55; + hiddenQRCanvas.height = 55; + + const sandbox = document.createElement('div'); + sandbox.style.position = 'fixed'; + sandbox.style.top = '-9999px'; + sandbox.style.left = '-9999px'; + sandbox.style.width = '1400px'; + document.body.appendChild(sandbox); + + const cardStyleBase = ` + position: relative; + width: 650px; + height: 1004px; + border-radius: 16px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + display: inline-block; + overflow: hidden; + font-family: Arial, Helvetica, sans-serif; + `; + + const renderOpts = { scale: 1, useCORS: true, allowTaint: false, backgroundColor: null, logging: false, width: 650, height: 1004 }; + + for (let i = 0; i < employees.length; i++) { + const emp = employees[i]; + const cleanName = emp.name.trim().replace(/\s+/g, '') || `Empleado_${i + 1}`; + const cleanClient = emp.selectedClient.trim().replace(/\s+/g, '') || 'Generico'; + + const folderName = `${cleanName}_${cleanClient}`; + + await renderQRCode(emp.employeeId, hiddenQRCanvas); + + const directDriveUrl = convertDriveUrlToDirect(emp.fotoUrl); + const validatedPhotoUrl = await preloadImage(directDriveUrl); + + // --- Renderizar Frente Virtual --- + const vFront = document.createElement('div'); + vFront.style.cssText = cardStyleBase; + vFront.style.backgroundImage = `url('${baseUrl}images/AF GLM Frente.png')`; + + let photoStyleHtml = `background: #e1e9ee; display: flex; align-items: center; justify-content: center;`; + let innerPhotoContent = ``; + + if (validatedPhotoUrl) { + photoStyleHtml = ` + background-image: url('${validatedPhotoUrl}') !important; + background-size: cover !important; + background-position: center center !important; + background-repeat: no-repeat !important; + `; + innerPhotoContent = ''; + } + + vFront.innerHTML = ` +
+ ${innerPhotoContent} +
+
+ ${emp.name.toUpperCase()} +
+
+ ${emp.role.toUpperCase()} +
+ `; + sandbox.appendChild(vFront); + + // --- Renderizar Reverso Virtual (Asignación exacta e idéntica según cliente e idioma) ─── + const currentGroup = clientTemplates[emp.language] || clientTemplates['Esp']; + + // Comprobación robusta de la existencia de la propiedad mapeada del cliente + const clientKey = currentGroup.hasOwnProperty(emp.selectedClient) ? emp.selectedClient : 'Generico'; + const backBgFile = currentGroup[clientKey]; + const backBgUrl = `${baseUrl}${backBgFile}`; + + const vBack = document.createElement('div'); + vBack.style.cssText = cardStyleBase; + vBack.style.backgroundImage = `url('${backBgUrl}')`; + + const vQRZone = document.createElement('div'); + vQRZone.style.cssText = `position: absolute; bottom: 27px; right: 27px; background: #ffffff; padding: 11px; border-radius: 16px; display: flex; align-items: center; justify-content: center; z-index: 5;`; + + const vQRCanvas = document.createElement('canvas'); + vQRCanvas.width = 149; + vQRCanvas.height = 149; + vQRCanvas.style.width = '100px'; + vQRCanvas.style.height = '100px'; + const vQRContext = vQRCanvas.getContext('2d'); + if (vQRContext) { + vQRContext.imageSmoothingEnabled = false; + vQRContext.drawImage(hiddenQRCanvas, 0, 0, 149, 149); + } + vQRZone.appendChild(vQRCanvas); + vBack.appendChild(vQRZone); + sandbox.appendChild(vBack); + + // Captura de datos gráficos + const frontCanvas = await html2canvasLib(vFront, renderOpts); + const frontData = frontCanvas.toDataURL('image/png').split(',')[1]; + zip.file(`${folderName}/${cleanName}_${cleanClient}_Frente.png`, frontData, { base64: true }); + + const backCanvas = await html2canvasLib(vBack, renderOpts); + const backData = backCanvas.toDataURL('image/png').split(',')[1]; + zip.file(`${folderName}/${cleanName}_${cleanClient}_Reverso.png`, backData, { base64: true }); + + sandbox.removeChild(vFront); + sandbox.removeChild(vBack); + } + + if (sandbox.parentNode) document.body.removeChild(sandbox); + + const content = await zip.generateAsync({ type: 'blob' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(content); + link.download = `Lote_Carnets_GLM.zip`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + if (onStateChange) onStateChange({ processing: false, text: '⬇ Descargar' }); +} + function executeFileDownload(dataUrl, fileName) { const link = document.createElement('a'); link.href = dataUrl;