From 710045ee1119236d27a525210264efc147d23400 Mon Sep 17 00:00:00 2001 From: EidanThen Date: Tue, 30 Jun 2026 15:08:52 -0400 Subject: [PATCH 1/9] 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; From 4ea71b7300e20f983ac7bd394fa652cb7393b6d7 Mon Sep 17 00:00:00 2001 From: EidanThen Date: Tue, 30 Jun 2026 16:18:14 -0400 Subject: [PATCH 2/9] feat: add IdCardGenerator component with bulk upload and Supabase sync support --- src/Generator.jsx | 53 ++++++++++++++++++++++++++++++---------- src/script/script.jsx | 56 +++++++++++++++++++++++++++++++++---------- 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/src/Generator.jsx b/src/Generator.jsx index ce28998..a5e5c04 100644 --- a/src/Generator.jsx +++ b/src/Generator.jsx @@ -25,7 +25,6 @@ export default function IdCardGenerator() { const excelInputRef = useRef(null); const qrCanvasRef = useRef(null); - // 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}/`; @@ -44,7 +43,36 @@ export default function IdCardGenerator() { } }; - // --- MAPEO DE COLUMNAS CORREGIDO --- + // --- ALMACENAMIENTO AUTOMÁTICO EN EL STORAGE E INSTANCIACIÓN RPC --- + const handleBulkSupabasePhotoSync = async (cedula, blobData) => { + try { + // Creamos un archivo sintáctico válido con tipo explícito image/jpeg + const customFile = new File([blobData], `${cedula}.jpg`, { type: 'image/jpeg' }); + + // Subida al Storage Bucket + const remoteStorageUrl = await uploadEmployeePhoto(cedula, customFile); + + if (remoteStorageUrl) { + // LLamado a tu función RPC nativa + const { data, error } = await supabase.rpc("save_employee_photo", { + p_employee_number: cedula, + p_photo_url: remoteStorageUrl + }); + + if (error) { + console.warn(`[Supabase RPC Error] Cédula ${cedula}:`, error.message); + } else if (data && data.success === false) { + console.log(`[Supabase Info] Cédula ${cedula}: ${data.message}`); + } else { + console.log(`[Supabase Success] Foto sincronizada para la Cédula: ${cedula}`); + } + } + } catch (error) { + console.error(`Sincronización abortada para la cédula ${cedula}:`, error); + } + }; + + // --- MAPEO DE COLUMNAS EXCEL CORREGIDO --- const handleExcelUpload = async (e) => { const file = e.target.files[0]; if (!file) return; @@ -68,7 +96,6 @@ export default function IdCardGenerator() { } 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'; @@ -78,14 +105,13 @@ export default function IdCardGenerator() { rawLang = 'Esp'; } - // 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" + .replace(/[\u0300-\u036f]/g, ""); - // Mapear nombres específicos para asegurar coincidencia exacta con las llaves de script.jsx const lowerClient = rawClient.toLowerCase(); + // CORRECCIÓN ESENCIAL: Dejar las llaves igual a script.jsx (Claro, Nestle, Whirlpool) sin .toUpperCase() if (lowerClient.includes('nestle')) rawClient = 'Nestle'; else if (lowerClient.includes('claro')) rawClient = 'Claro'; else if (lowerClient.includes('colgate')) rawClient = 'Colgate'; @@ -102,7 +128,7 @@ export default function IdCardGenerator() { role: String(row.puesto || '').trim(), selectedClient: rawClient, language: rawLang, - employeeId: String(row.cedula || '').trim(), // CORRECCIÓN: Columna individual 'cedula' + employeeId: String(row.cedula || '').trim(), fotoUrl: String(row.foto_url || '').trim() }; }).filter(emp => emp.name && emp.role); @@ -127,9 +153,10 @@ export default function IdCardGenerator() { await downloadBulkIDCards({ employees: bulkEmployees, baseUrl, - onStateChange: setBulkDownloadStatus + onStateChange: setBulkDownloadStatus, + onProcessEmployeePhoto: handleBulkSupabasePhotoSync }); - setBulkStatusText('🎉 ¡Lote procesado y guardado!'); + setBulkStatusText('🎉 ¡Lote guardado y sincronizado con Supabase!'); } catch (error) { console.error(error); alert("Error procesando las imágenes remotas."); @@ -176,7 +203,7 @@ export default function IdCardGenerator() {
- {/* FORMULARIO MASIVO EXCEL (IZQUIERDA) */} + {/* FORMULARIO MASIVO EXCEL */}
@@ -209,7 +236,7 @@ export default function IdCardGenerator() { }}> 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. + Las imágenes se guardarán automáticamente en Supabase si no tienen una previa asignada.
)}
@@ -247,7 +274,7 @@ export default function IdCardGenerator() {
- {/* FORMULARIO INDIVIDUAL (CENTRO) */} + {/* FORMULARIO INDIVIDUAL */}
@@ -318,7 +345,7 @@ export default function IdCardGenerator() {
- {/* VISTA PREVIA (DERECHA) */} + {/* VISTA PREVIA */}
Frente
diff --git a/src/script/script.jsx b/src/script/script.jsx index 8310fc0..95d1c45 100644 --- a/src/script/script.jsx +++ b/src/script/script.jsx @@ -42,7 +42,7 @@ const getHtml2Canvas = () => loadScript('https://cdnjs.cloudflare.com/ajax/libs/ 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'); -function convertDriveUrlToDirect(url) { +export function convertDriveUrlToDirect(url) { if (!url) return ''; const regExp = /(?:https?:\/\/)?(?:drive\.google\.com\/)(?:file\/d\/|open\?id=)([\w-]+)/; const match = url.match(regExp); @@ -52,6 +52,27 @@ function convertDriveUrlToDirect(url) { return url; } +// Descarga la imagen evadiendo las restricciones CORS de Google Drive usando un CDN/Proxy transparente +export async function fetchImageAsBlob(url) { + if (!url) return null; + try { + // Codificamos la URL directa de drive y la pasamos por el proxy weserv.nl para saltar el CORS de manera segura + const proxyUrl = `https://images.weserv.nl/?url=${encodeURIComponent(url)}&default=${encodeURIComponent(url)}`; + const response = await fetch(proxyUrl); + if (!response.ok) throw new Error("Error en respuesta de red proxy"); + return await response.blob(); + } catch (e) { + console.error("Error haciendo fetch con bypass CORS a la imagen:", e); + try { + // Intento secundario fallback por si falla el proxy + const response = await fetch(url, { mode: 'cors' }); + return await response.blob(); + } catch (err) { + return null; + } + } +} + function preloadImage(src) { return new Promise((resolve) => { if (!src) return resolve(null); @@ -226,7 +247,7 @@ export async function downloadIDCards(config) { } // ── ENGINE MASIVO CON CORRESPONDENCIA DINÁMICA DE REVERSO POR IDIOMA ── -export async function downloadBulkIDCards({ employees, baseUrl, onStateChange }) { +export async function downloadBulkIDCards({ employees, baseUrl, onStateChange, onProcessEmployeePhoto }) { if (onStateChange) onStateChange({ processing: true, text: 'Descargando...' }); const html2canvasLib = await getHtml2Canvas(); @@ -261,14 +282,26 @@ export async function downloadBulkIDCards({ employees, baseUrl, onStateChange }) for (let i = 0; i < employees.length; i++) { const emp = employees[i]; + const currentClient = emp.selectedClient || 'Generico'; const cleanName = emp.name.trim().replace(/\s+/g, '') || `Empleado_${i + 1}`; - const cleanClient = emp.selectedClient.trim().replace(/\s+/g, '') || 'Generico'; - - const folderName = `${cleanName}_${cleanClient}`; + const folderName = `${cleanName}_${currentClient}`; await renderQRCode(emp.employeeId, hiddenQRCanvas); const directDriveUrl = convertDriveUrlToDirect(emp.fotoUrl); + + // --- PROCESAMIENTO E INYECCIÓN BINARIA A SUPABASE --- + if (onProcessEmployeePhoto && directDriveUrl && emp.employeeId) { + try { + const blobData = await fetchImageAsBlob(directDriveUrl); + if (blobData) { + await onProcessEmployeePhoto(emp.employeeId, blobData); + } + } catch (err) { + console.error(`Error guardando foto en Supabase para Cédula: ${emp.employeeId}`, err); + } + } + const validatedPhotoUrl = await preloadImage(directDriveUrl); // --- Renderizar Frente Virtual --- @@ -302,12 +335,9 @@ export async function downloadBulkIDCards({ employees, baseUrl, onStateChange }) `; sandbox.appendChild(vFront); - // --- Renderizar Reverso Virtual (Asignación exacta e idéntica según cliente e idioma) ─── + // --- Renderizar Reverso Virtual --- 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 backBgFile = currentGroup[currentClient] || currentGroup['Generico']; const backBgUrl = `${baseUrl}${backBgFile}`; const vBack = document.createElement('div'); @@ -331,14 +361,14 @@ export async function downloadBulkIDCards({ employees, baseUrl, onStateChange }) vBack.appendChild(vQRZone); sandbox.appendChild(vBack); - // Captura de datos gráficos + // Capturas gráficas del ZIP const frontCanvas = await html2canvasLib(vFront, renderOpts); const frontData = frontCanvas.toDataURL('image/png').split(',')[1]; - zip.file(`${folderName}/${cleanName}_${cleanClient}_Frente.png`, frontData, { base64: true }); + zip.file(`${folderName}/${cleanName}_${currentClient}_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 }); + zip.file(`${folderName}/${cleanName}_${currentClient}_Reverso.png`, backData, { base64: true }); sandbox.removeChild(vFront); sandbox.removeChild(vBack); From 4b4685675c385f0adc6542c2d93e30469d83c867 Mon Sep 17 00:00:00 2001 From: EidanThen Date: Fri, 3 Jul 2026 15:02:47 -0400 Subject: [PATCH 3/9] feat: implement core ID card generation interface with photo upload and bulk data processing support --- src/App.css | 9 ++-- src/Generator.jsx | 53 +++++++++++-------- src/script/script.jsx | 117 +++++++++++++++++++++++++++--------------- 3 files changed, 111 insertions(+), 68 deletions(-) diff --git a/src/App.css b/src/App.css index 7de3b42..d99dd91 100644 --- a/src/App.css +++ b/src/App.css @@ -45,6 +45,7 @@ body { justify-content: center; align-items: flex-start; flex-direction: row; + margin-top: 30px } /* ── CONTROLS PANEL ── */ @@ -255,11 +256,11 @@ body { /* ── FRONT DYNAMIC OVERLAYS ── */ .front-photo-frame { position: absolute; - top: 99px; - left: 50%; + top: 98.5px; + left: 49.9%; transform: translateX(-50%); - width: 145px; - height: 145px; + width: 145.5px; + height: 145.5px; border-radius: 50%; overflow: hidden; display: flex; diff --git a/src/Generator.jsx b/src/Generator.jsx index a5e5c04..4de464f 100644 --- a/src/Generator.jsx +++ b/src/Generator.jsx @@ -29,6 +29,7 @@ export default function IdCardGenerator() { ? import.meta.env.BASE_URL : `${import.meta.env.BASE_URL}/`; + // Al limpiar el input en tiempo real, employeeId ya vendrá totalmente limpio useEffect(() => { if (qrCanvasRef.current) { renderQRCode(employeeId, qrCanvasRef.current); @@ -43,36 +44,41 @@ export default function IdCardGenerator() { } }; + // Interceptor para bloquear en tiempo real guiones y espacios en blanco + const handleEmployeeIdChange = (e) => { + const rawValue = e.target.value; + // Reemplaza instantáneamente cualquier guión o espacio por vacío + const cleanValue = rawValue.replace(/[-\s]/g, ''); + setEmployeeId(cleanValue); + }; + // --- ALMACENAMIENTO AUTOMÁTICO EN EL STORAGE E INSTANCIACIÓN RPC --- const handleBulkSupabasePhotoSync = async (cedula, blobData) => { + const cleanCedula = String(cedula).replace(/[-\s]/g, ''); try { - // Creamos un archivo sintáctico válido con tipo explícito image/jpeg - const customFile = new File([blobData], `${cedula}.jpg`, { type: 'image/jpeg' }); - - // Subida al Storage Bucket - const remoteStorageUrl = await uploadEmployeePhoto(cedula, customFile); + const customFile = new File([blobData], `${cleanCedula}.jpg`, { type: 'image/jpeg' }); + const remoteStorageUrl = await uploadEmployeePhoto(cleanCedula, customFile); if (remoteStorageUrl) { - // LLamado a tu función RPC nativa const { data, error } = await supabase.rpc("save_employee_photo", { - p_employee_number: cedula, + p_employee_number: cleanCedula, p_photo_url: remoteStorageUrl }); if (error) { - console.warn(`[Supabase RPC Error] Cédula ${cedula}:`, error.message); + console.warn(`[Supabase RPC Error] Cédula ${cleanCedula}:`, error.message); } else if (data && data.success === false) { - console.log(`[Supabase Info] Cédula ${cedula}: ${data.message}`); + console.log(`[Supabase Info] Cédula ${cleanCedula}: ${data.message}`); } else { - console.log(`[Supabase Success] Foto sincronizada para la Cédula: ${cedula}`); + console.log(`[Supabase Success] Foto sincronizada para la Cédula: ${cleanCedula}`); } } } catch (error) { - console.error(`Sincronización abortada para la cédula ${cedula}:`, error); + console.error(`Sincronización abortada para la cédula ${cleanCedula}:`, error); } }; - // --- MAPEO DE COLUMNAS EXCEL CORREGIDO --- + // --- MAPEO DE COLUMNAS EXCEL CON LIMPIEZA DE CÉDULA --- const handleExcelUpload = async (e) => { const file = e.target.files[0]; if (!file) return; @@ -111,7 +117,6 @@ export default function IdCardGenerator() { .replace(/[\u0300-\u036f]/g, ""); const lowerClient = rawClient.toLowerCase(); - // CORRECCIÓN ESENCIAL: Dejar las llaves igual a script.jsx (Claro, Nestle, Whirlpool) sin .toUpperCase() if (lowerClient.includes('nestle')) rawClient = 'Nestle'; else if (lowerClient.includes('claro')) rawClient = 'Claro'; else if (lowerClient.includes('colgate')) rawClient = 'Colgate'; @@ -123,15 +128,17 @@ export default function IdCardGenerator() { else if (lowerClient.includes('whirlpool')) rawClient = 'Whirlpool'; else rawClient = 'Generico'; + const cleanedCedula = String(row.cedula || '').replace(/[-\s]/g, ''); + return { name: String(row.nombre || '').trim(), role: String(row.puesto || '').trim(), selectedClient: rawClient, language: rawLang, - employeeId: String(row.cedula || '').trim(), + employeeId: cleanedCedula, fotoUrl: String(row.foto_url || '').trim() }; - }).filter(emp => emp.name && emp.role); + }).filter(emp => emp.name && emp.role && emp.employeeId); setBulkEmployees(formatted); setBulkStatusText(`✅ ¡Cargados ${formatted.length} colaboradores con éxito!`); @@ -156,7 +163,7 @@ export default function IdCardGenerator() { onStateChange: setBulkDownloadStatus, onProcessEmployeePhoto: handleBulkSupabasePhotoSync }); - setBulkStatusText('🎉 ¡Lote guardado y sincronizado con Supabase!'); + setBulkStatusText('🎉 ¡Lote guardado!'); } catch (error) { console.error(error); alert("Error procesando las imágenes remotas."); @@ -202,7 +209,6 @@ export default function IdCardGenerator() {
- {/* FORMULARIO MASIVO EXCEL */}
@@ -236,7 +242,6 @@ export default function IdCardGenerator() { }}> Formato de columnas requeridas en el Excel:
nombre, puesto, cliente/proyecto, cedula, lenguaje, foto_url.
- Las imágenes se guardarán automáticamente en Supabase si no tienen una previa asignada.
)}
@@ -327,8 +332,13 @@ export default function IdCardGenerator() {
Código QR
- - setEmployeeId(e.target.value)} /> + +
-
); diff --git a/src/script/script.jsx b/src/script/script.jsx index 95d1c45..084fb02 100644 --- a/src/script/script.jsx +++ b/src/script/script.jsx @@ -44,27 +44,45 @@ export const getXLSXLib = () => loadScript('https://cdnjs.cloudflare.com/ajax/li export 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]}`; + + if (!url.includes('drive.google.com') && !url.includes('docs.google.com')) { + return url; + } + + try { + const regExp = /(?:https?:\/\/)?(?:drive\.google\.com\/)(?:file\/d\/|open\?id=)([\w-]+)/; + const match = url.match(regExp); + if (match && match[1]) { + // Endpoint optimizado para saltar bloqueos pesados de descargas directas anónimas + return `https://drive.google.com/thumbnail?id=${match[1]}&sz=w1000`; + } + } catch (e) { + console.error("Error convirtiendo la URL de Drive:", e); } return url; } -// Descarga la imagen evadiendo las restricciones CORS de Google Drive usando un CDN/Proxy transparente export async function fetchImageAsBlob(url) { if (!url) return null; + + if (url.includes('drive.google.com') || url.includes('docs.google.com')) { + try { + const response = await fetch(url); + if (response.ok) return await response.blob(); + } catch (e) { + console.warn("No se pudo obtener el Blob directo de Drive debido a restricciones CORS."); + } + return null; + } + try { - // Codificamos la URL directa de drive y la pasamos por el proxy weserv.nl para saltar el CORS de manera segura const proxyUrl = `https://images.weserv.nl/?url=${encodeURIComponent(url)}&default=${encodeURIComponent(url)}`; const response = await fetch(proxyUrl); if (!response.ok) throw new Error("Error en respuesta de red proxy"); return await response.blob(); } catch (e) { - console.error("Error haciendo fetch con bypass CORS a la imagen:", e); + console.error("Error haciendo fetch con bypass CORS a la imagen externa:", e); try { - // Intento secundario fallback por si falla el proxy const response = await fetch(url, { mode: 'cors' }); return await response.blob(); } catch (err) { @@ -79,19 +97,24 @@ function preloadImage(src) { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(src); - img.onerror = () => resolve(null); + img.onerror = () => { + const retryImg = new Image(); + retryImg.onload = () => resolve(src); + retryImg.onerror = () => resolve(null); + retryImg.src = src; + }; img.src = src; }); } -// ── GENERADOR DE QR NATIVO ── +// ── GENERADOR DE QR NATIVO (Garantiza ID limpio en URL) ── export async function renderQRCode(employeeId, canvasElement) { if (!canvasElement) return; - const text = (employeeId || '').trim(); + const cleanId = (employeeId || '').replace(/[-\s]/g, ''); const ctx = canvasElement.getContext('2d'); ctx.clearRect(0, 0, canvasElement.width, canvasElement.height); - if (text.length <= 2) return; + if (cleanId.length <= 2) return; const QRCodeLib = await getQRLib(); const tmp = document.createElement('div'); @@ -99,7 +122,7 @@ export async function renderQRCode(employeeId, canvasElement) { document.body.appendChild(tmp); new QRCodeLib(tmp, { - text: `http://localhost:5173/employee/${text}`, + text: `http://localhost:5173/employee/${cleanId}`, width: 55, height: 55, colorDark: '#000000', @@ -181,22 +204,19 @@ export async function downloadIDCards(config) { vFront.style.cssText = cardStyleBase; vFront.style.backgroundImage = `url('${frontBgUrl}')`; - let photoStyleHtml = `background: #e1e9ee; display: flex; align-items: center; justify-content: center;`; - let innerPhotoContent = ``; + let photoTagHtml = ` +
+ +
+ `; if (photoSrc) { - photoStyleHtml = ` - background-image: url('${photoSrc}') !important; - background-size: cover !important; - background-position: center center !important; - background-repeat: no-repeat !important; - `; - innerPhotoContent = ''; + photoTagHtml = ``; } vFront.innerHTML = ` -
- ${innerPhotoContent} +
+ ${photoTagHtml}
${name.toUpperCase() || 'NOMBRE APELLIDO'} @@ -246,7 +266,7 @@ export async function downloadIDCards(config) { } } -// ── ENGINE MASIVO CON CORRESPONDENCIA DINÁMICA DE REVERSO POR IDIOMA ── +// ── ENGINE MASIVO CON SOLUCIÓN TOTAL POR INYECCIÓN DE IMG CORS NATIVA ── export async function downloadBulkIDCards({ employees, baseUrl, onStateChange, onProcessEmployeePhoto }) { if (onStateChange) onStateChange({ processing: true, text: 'Descargando...' }); @@ -286,45 +306,51 @@ export async function downloadBulkIDCards({ employees, baseUrl, onStateChange, o const cleanName = emp.name.trim().replace(/\s+/g, '') || `Empleado_${i + 1}`; const folderName = `${cleanName}_${currentClient}`; - await renderQRCode(emp.employeeId, hiddenQRCanvas); + const strictCleanId = String(emp.employeeId || '').replace(/[-\s]/g, ''); + + await renderQRCode(strictCleanId, hiddenQRCanvas); const directDriveUrl = convertDriveUrlToDirect(emp.fotoUrl); + let validatedPhotoUrl = null; - // --- PROCESAMIENTO E INYECCIÓN BINARIA A SUPABASE --- - if (onProcessEmployeePhoto && directDriveUrl && emp.employeeId) { + if (directDriveUrl && strictCleanId) { try { const blobData = await fetchImageAsBlob(directDriveUrl); if (blobData) { - await onProcessEmployeePhoto(emp.employeeId, blobData); + validatedPhotoUrl = URL.createObjectURL(blobData); + if (onProcessEmployeePhoto) { + await onProcessEmployeePhoto(strictCleanId, blobData); + } } } catch (err) { - console.error(`Error guardando foto en Supabase para Cédula: ${emp.employeeId}`, err); + console.error(`Error guardando foto en Supabase para Cédula: ${strictCleanId}`, err); } } - const validatedPhotoUrl = await preloadImage(directDriveUrl); + if (!validatedPhotoUrl && directDriveUrl) { + 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 = ``; + // CAMBIO ESTRATÉGICO AQUÍ: Usamos una etiqueta HTML real con crossorigin explícito, + // esto destruye el bloqueo "Anti-Taint" de los lienzos y obliga a html2canvas a procesar la foto de Drive. + let photoTagHtml = ` +
+ +
+ `; if (validatedPhotoUrl) { - photoStyleHtml = ` - background-image: url('${validatedPhotoUrl}') !important; - background-size: cover !important; - background-position: center center !important; - background-repeat: no-repeat !important; - `; - innerPhotoContent = ''; + photoTagHtml = ``; } vFront.innerHTML = ` -
- ${innerPhotoContent} +
+ ${photoTagHtml}
${emp.name.toUpperCase()} @@ -361,6 +387,9 @@ export async function downloadBulkIDCards({ employees, baseUrl, onStateChange, o vBack.appendChild(vQRZone); sandbox.appendChild(vBack); + // Forzar un retraso mínimo de microsegundos para asegurar el render interno + await new Promise((resolve) => setTimeout(resolve, 30)); + // Capturas gráficas del ZIP const frontCanvas = await html2canvasLib(vFront, renderOpts); const frontData = frontCanvas.toDataURL('image/png').split(',')[1]; @@ -370,6 +399,10 @@ export async function downloadBulkIDCards({ employees, baseUrl, onStateChange, o const backData = backCanvas.toDataURL('image/png').split(',')[1]; zip.file(`${folderName}/${cleanName}_${currentClient}_Reverso.png`, backData, { base64: true }); + if (validatedPhotoUrl && validatedPhotoUrl.startsWith('blob:')) { + URL.revokeObjectURL(validatedPhotoUrl); + } + sandbox.removeChild(vFront); sandbox.removeChild(vBack); } From f172d62ed430d09735c5c2fd9f532a384a80f546 Mon Sep 17 00:00:00 2001 From: EidanThen Date: Mon, 6 Jul 2026 19:53:50 -0400 Subject: [PATCH 4/9] feat: implement ID card generator with bulk upload, Excel parsing, Supabase and n8n integration --- package-lock.json | 223 ++++++++++++++++++++++++++++++++- package.json | 6 +- src/{App.css => Generator.css} | 0 src/Generator.jsx | 200 +++++++++++++++++++++++++++-- src/script/script.jsx | 23 +++- 5 files changed, 434 insertions(+), 18 deletions(-) rename src/{App.css => Generator.css} (100%) diff --git a/package-lock.json b/package-lock.json index 0a4d929..be8a727 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,13 @@ "version": "0.0.0", "dependencies": { "@supabase/supabase-js": "^2.108.2", + "file-saver": "^2.0.5", + "jszip": "^3.10.1", + "lucide-react": "^1.23.0", "react": "^19.2.7", "react-dom": "^19.2.7", - "react-router-dom": "^7.18.1" + "react-router-dom": "^7.18.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -1039,6 +1043,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -1147,6 +1160,28 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1167,6 +1202,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1491,6 +1544,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1529,6 +1588,15 @@ "dev": true, "license": "ISC" }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1616,6 +1684,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -1626,6 +1700,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1649,6 +1729,12 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1710,6 +1796,18 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -1734,6 +1832,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2033,6 +2140,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", + "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2142,6 +2258,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2221,6 +2343,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2290,6 +2418,21 @@ "react-dom": ">=18" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/rolldown": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", @@ -2324,6 +2467,12 @@ "@rolldown/binding-win32-x64-msvc": "1.1.3" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2346,6 +2495,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2379,6 +2534,27 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2456,6 +2632,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", @@ -2550,6 +2732,24 @@ "node": ">= 8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -2560,6 +2760,27 @@ "node": ">=0.10.0" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index ee39097..ad8923f 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,13 @@ }, "dependencies": { "@supabase/supabase-js": "^2.108.2", + "file-saver": "^2.0.5", + "jszip": "^3.10.1", + "lucide-react": "^1.23.0", "react": "^19.2.7", "react-dom": "^19.2.7", - "react-router-dom": "^7.18.1" + "react-router-dom": "^7.18.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/App.css b/src/Generator.css similarity index 100% rename from src/App.css rename to src/Generator.css diff --git a/src/Generator.jsx b/src/Generator.jsx index 4de464f..b0647af 100644 --- a/src/Generator.jsx +++ b/src/Generator.jsx @@ -1,9 +1,48 @@ -import React, { useState, useRef, useEffect } from 'react'; -import './App.css'; +import { useState, useRef, useEffect } from 'react'; +import './Generator.css'; import { renderQRCode, downloadIDCards, downloadBulkIDCards, getXLSXLib } from './script/script.jsx'; import { uploadEmployeePhoto } from './services/storage.js'; import { supabase } from "./services/supabase"; +// Convierte datos binarios recibidos (base64, buffer, array de bytes) en un Blob URL local de forma robusta +const convertBinaryToBlobUrl = (data) => { + if (!data) return null; + try { + // Si es un objeto tipo Buffer de Node.js/n8n (p. ej., { type: 'Buffer', data: [...] }) + if (typeof data === 'object' && data.type === 'Buffer' && Array.isArray(data.data)) { + const byteArray = new Uint8Array(data.data); + const blob = new Blob([byteArray], { type: 'image/jpeg' }); + return URL.createObjectURL(blob); + } + + // Si ya es un array de números (bytes) + if (Array.isArray(data)) { + const byteArray = new Uint8Array(data); + const blob = new Blob([byteArray], { type: 'image/jpeg' }); + return URL.createObjectURL(blob); + } + + // Si es un string + if (typeof data === 'string') { + // Limpiar prefijo data URL si existe + const cleanBase64 = data.replace(/^data:image\/\w+;base64,/, ""); + + // Decodificar Base64 + const byteCharacters = atob(cleanBase64); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { type: 'image/jpeg' }); + return URL.createObjectURL(blob); + } + } catch (e) { + console.error("Error al convertir datos binarios a Blob URL:", e); + } + return null; +}; + export default function IdCardGenerator() { // --- Estados formulario individual ─── const [name, setName] = useState(''); @@ -18,7 +57,7 @@ export default function IdCardGenerator() { // --- Estados Lote / Excel Masivo ─── const [bulkEmployees, setBulkEmployees] = useState([]); const [bulkStatusText, setBulkStatusText] = useState(''); - const [bulkDownloadStatus, setBulkDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' }); + const [bulkDownloadStatus, setBulkDownloadStatus] = useState({ processing: false, text: '⬇ Descargar Carnets' }); const [showTooltip, setShowTooltip] = useState(false); const fileInputRef = useRef(null); @@ -52,6 +91,62 @@ export default function IdCardGenerator() { setEmployeeId(cleanValue); }; + // --- Envío directo a n8n para recuperar las fotos procesadas --- + const enviarDatosAn8n = async (datosEmpleados) => { + const N8N_WEBHOOK_URL = import.meta.env.VITE_N8N_WEBHOOK_URL; + const TOKEN_SECRETO = import.meta.env.VITE_WEBHOOK_TOKEN; + + try { + const response = await fetch(N8N_WEBHOOK_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': TOKEN_SECRETO + }, + body: JSON.stringify({ + empleados: datosEmpleados, + total: datosEmpleados.length, + fechaProcesado: new Date().toISOString() + }) + }); + + if (!response.ok) { + console.error('Error en n8n:', response.status); + throw new Error(`El servidor de n8n rechazó la petición (Código: ${response.status})`); + } + + const rawData = await response.json(); + console.log('Respuesta cruda de n8n:', rawData); + + let dataArray = []; + if (Array.isArray(rawData)) { + dataArray = rawData; + } else if (rawData && typeof rawData === 'object') { + const keys = ['empleados', 'data', 'result', 'employees', 'items', 'output']; + for (const key of keys) { + if (Array.isArray(rawData[key])) { + dataArray = rawData[key]; + break; + } + } + if (dataArray.length === 0 && (rawData.name || rawData.employeeId || rawData.data || rawData.json)) { + dataArray = [rawData]; + } + } + + // Normalizar el envoltorio `{ json: ... }` si viene de n8n + return dataArray.map(item => { + if (item && item.json && typeof item.json === 'object') { + return item.json; + } + return item; + }); + } catch (error) { + console.error('Error de red enviando a n8n:', error); + throw error; + } + }; + // --- ALMACENAMIENTO AUTOMÁTICO EN EL STORAGE E INSTANCIACIÓN RPC --- const handleBulkSupabasePhotoSync = async (cedula, blobData) => { const cleanCedula = String(cedula).replace(/[-\s]/g, ''); @@ -78,7 +173,7 @@ export default function IdCardGenerator() { } }; - // --- MAPEO DE COLUMNAS EXCEL CON LIMPIEZA DE CÉDULA --- + // --- MAPEO DE COLUMNAS EXCEL --- const handleExcelUpload = async (e) => { const file = e.target.files[0]; if (!file) return; @@ -88,7 +183,7 @@ export default function IdCardGenerator() { const XLSXLib = await getXLSXLib(); const reader = new FileReader(); - reader.onload = (evt) => { + reader.onload = async (evt) => { try { const bstr = evt.target.result; const wb = XLSXLib.read(bstr, { type: 'binary' }); @@ -141,7 +236,9 @@ export default function IdCardGenerator() { }).filter(emp => emp.name && emp.role && emp.employeeId); setBulkEmployees(formatted); - setBulkStatusText(`✅ ¡Cargados ${formatted.length} colaboradores con éxito!`); + setBulkStatusText(`✅ ¡Cargados ${formatted.length} colaboradores! Listo para procesar.`); + setBulkDownloadStatus({ processing: false, text: '⬇ Descargar Carnets' }); + } catch (innerErr) { console.error(innerErr); setBulkStatusText('❌ Error de lectura. Revisa el formato de columnas.'); @@ -154,19 +251,102 @@ export default function IdCardGenerator() { } }; + // --- ACCIÓN DEL BOTÓN CORREGIDA CON FINALLY Y MAPEO TOTAL DE VARIABLES --- const triggerBulkDownload = async () => { if (bulkEmployees.length === 0) return; + + setBulkDownloadStatus({ processing: true, text: '🔄 Descargando...' }); + setBulkStatusText('Conectando con n8n y descargando retratos optimizados por IA...'); + try { + // 1. Obtenemos las imágenes crudas en Base64/Binario desde el webhook de n8n + const resultadosN8n = await enviarDatosAn8n(bulkEmployees); + + if (!resultadosN8n || resultadosN8n.length === 0) { + throw new Error('No se recibieron colaboradores procesados desde n8n.'); + } + + setBulkStatusText('Generando frentes, reversos y empaquetando en carpetas locales...'); + + // 2. Mapeamos garantizando que TODAS las propiedades originales existan para script.jsx + const employeesReadyForRender = resultadosN8n.map((n8nEmp, index) => { + // Buscamos la fila correspondiente en el Excel original (por employeeId o nombre) para mantener cliente + const n8nId = String(n8nEmp.employeeId || n8nEmp.employee_id || n8nEmp.cedula || n8nEmp.id || '').replace(/[-\s]/g, ''); + const n8nName = String(n8nEmp.name || n8nEmp.nombre || '').trim().toLowerCase(); + + const originalEmp = bulkEmployees.find(e => { + const origId = String(e.employeeId).replace(/[-\s]/g, ''); + const origName = String(e.name).trim().toLowerCase(); + return (n8nId && origId === n8nId) || (n8nName && origName === n8nName); + }) || bulkEmployees[index] || {}; + + // Mapeo/limpieza de cliente/proyecto + let rawClient = n8nEmp.selectedClient || n8nEmp.client || n8nEmp.proyecto || n8nEmp['cliente/proyecto'] || originalEmp.selectedClient || 'Generico'; + const lowerClient = String(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'; + + // Mapeo/conversión de la imagen del empleado + let localPhotoUrl = originalEmp.fotoUrl || ''; + const imageData = n8nEmp.data || n8nEmp.foto_processed_base64 || n8nEmp.binary || n8nEmp.image || originalEmp.data; + + if (imageData) { + const blobUrl = convertBinaryToBlobUrl(imageData); + if (blobUrl) { + localPhotoUrl = blobUrl; + } + } + + // Normalización de idioma + let rawLang = String(n8nEmp.language || n8nEmp.lenguaje || n8nEmp.idioma || originalEmp.language || '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'; + } + + // Inyectamos las claves exactas que script.jsx desestructura + return { + name: String(n8nEmp.name || n8nEmp.nombre || originalEmp.name || '').trim(), + role: String(n8nEmp.role || n8nEmp.puesto || originalEmp.role || '').trim(), + selectedClient: rawClient, + language: rawLang, + employeeId: n8nId || String(originalEmp.employeeId || '').replace(/[-\s]/g, ''), + fotoUrl: localPhotoUrl + }; + }).filter(emp => emp.name && emp.employeeId); + + if (employeesReadyForRender.length === 0) { + throw new Error('Ningún colaborador posee datos válidos de nombre e identificación para procesar.'); + } + + // 3. Ejecutamos tu motor original en script.jsx que procesará los diseños de Claro, Nestle, etc. await downloadBulkIDCards({ - employees: bulkEmployees, + employees: employeesReadyForRender, baseUrl, - onStateChange: setBulkDownloadStatus, + onStateChange: null, // Evitamos sobreescritura conflictiva de estados dentro del script onProcessEmployeePhoto: handleBulkSupabasePhotoSync }); - setBulkStatusText('🎉 ¡Lote guardado!'); + + setBulkStatusText('🎉 ¡Lote de carnets guardado correctamente por carpetas!'); + setBulkEmployees([]); + } catch (error) { console.error(error); - alert("Error procesando las imágenes remotas."); + setBulkStatusText(`❌ Error al procesar: ${error.message || error}`); + } finally { + // El bloque finally se ejecuta SIEMPRE (tenga éxito o falle), liberando el botón de forma segura + setBulkDownloadStatus({ processing: false, text: '⬇ Descargar Carnets' }); } }; diff --git a/src/script/script.jsx b/src/script/script.jsx index 084fb02..983cda1 100644 --- a/src/script/script.jsx +++ b/src/script/script.jsx @@ -65,11 +65,21 @@ export function convertDriveUrlToDirect(url) { export async function fetchImageAsBlob(url) { if (!url) return null; - if (url.includes('drive.google.com') || url.includes('docs.google.com')) { + if (url.startsWith('blob:') || url.startsWith('data:')) { try { const response = await fetch(url); if (response.ok) return await response.blob(); } catch (e) { + console.error("Error al obtener Blob local de URL blob/data:", e); + } + return null; + } + + if (url.includes('drive.google.com') || url.includes('docs.google.com')) { + try { + const response = await fetch(url); + if (response.ok) return await response.blob(); + } catch { console.warn("No se pudo obtener el Blob directo de Drive debido a restricciones CORS."); } return null; @@ -85,7 +95,7 @@ export async function fetchImageAsBlob(url) { try { const response = await fetch(url, { mode: 'cors' }); return await response.blob(); - } catch (err) { + } catch { return null; } } @@ -303,8 +313,9 @@ export async function downloadBulkIDCards({ employees, baseUrl, onStateChange, o for (let i = 0; i < employees.length; i++) { const emp = employees[i]; const currentClient = emp.selectedClient || 'Generico'; - const cleanName = emp.name.trim().replace(/\s+/g, '') || `Empleado_${i + 1}`; - const folderName = `${cleanName}_${currentClient}`; + const sanitizedEmpName = (emp.name || '').trim().replace(/[\\/:*?"<>|]/g, '-') || `Empleado_${i + 1}`; + const sanitizedClientName = String(currentClient).trim().replace(/[\\/:*?"<>|]/g, '-'); + const folderName = `${sanitizedEmpName} - ${sanitizedClientName}`; const strictCleanId = String(emp.employeeId || '').replace(/[-\s]/g, ''); @@ -393,11 +404,11 @@ export async function downloadBulkIDCards({ employees, baseUrl, onStateChange, o // Capturas gráficas del ZIP const frontCanvas = await html2canvasLib(vFront, renderOpts); const frontData = frontCanvas.toDataURL('image/png').split(',')[1]; - zip.file(`${folderName}/${cleanName}_${currentClient}_Frente.png`, frontData, { base64: true }); + zip.file(`${folderName}/Frente.png`, frontData, { base64: true }); const backCanvas = await html2canvasLib(vBack, renderOpts); const backData = backCanvas.toDataURL('image/png').split(',')[1]; - zip.file(`${folderName}/${cleanName}_${currentClient}_Reverso.png`, backData, { base64: true }); + zip.file(`${folderName}/Reverso.png`, backData, { base64: true }); if (validatedPhotoUrl && validatedPhotoUrl.startsWith('blob:')) { URL.revokeObjectURL(validatedPhotoUrl); From cb0636c9577282ba0b81c12bb45dee862f55dd5f Mon Sep 17 00:00:00 2001 From: EidanThen Date: Mon, 6 Jul 2026 22:05:31 -0400 Subject: [PATCH 5/9] feat: allow duplicated employee images in Supabase storage --- src/Generator.jsx | 16 ++++++++---- src/script/script.jsx | 2 +- src/services/storage.js | 54 +++++++++++++++++++++++++++++++++++------ 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/Generator.jsx b/src/Generator.jsx index b0647af..7a1f14e 100644 --- a/src/Generator.jsx +++ b/src/Generator.jsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from 'react'; import './Generator.css'; -import { renderQRCode, downloadIDCards, downloadBulkIDCards, getXLSXLib } from './script/script.jsx'; +import { renderQRCode, downloadIDCards, downloadBulkIDCards, getXLSXLib, clientTemplates } from './script/script.jsx'; import { uploadEmployeePhoto } from './services/storage.js'; import { supabase } from "./services/supabase"; @@ -148,11 +148,11 @@ export default function IdCardGenerator() { }; // --- ALMACENAMIENTO AUTOMÁTICO EN EL STORAGE E INSTANCIACIÓN RPC --- - const handleBulkSupabasePhotoSync = async (cedula, blobData) => { + const handleBulkSupabasePhotoSync = async (cedula, blobData, clientKey = '') => { const cleanCedula = String(cedula).replace(/[-\s]/g, ''); try { const customFile = new File([blobData], `${cleanCedula}.jpg`, { type: 'image/jpeg' }); - const remoteStorageUrl = await uploadEmployeePhoto(cleanCedula, customFile); + const remoteStorageUrl = await uploadEmployeePhoto(cleanCedula, customFile, clientKey); if (remoteStorageUrl) { const { data, error } = await supabase.rpc("save_employee_photo", { @@ -354,7 +354,7 @@ export default function IdCardGenerator() { if (!validateForm()) return; try { if (photoFile) { - const photoUrl = await uploadEmployeePhoto(employeeId, photoFile); + const photoUrl = await uploadEmployeePhoto(employeeId, photoFile, selectedClient); await supabase.rpc("save_employee_photo", { p_employee_number: employeeId, p_photo_url: photoUrl @@ -557,7 +557,13 @@ export default function IdCardGenerator() {
Reverso
-
+
diff --git a/src/script/script.jsx b/src/script/script.jsx index 983cda1..112416a 100644 --- a/src/script/script.jsx +++ b/src/script/script.jsx @@ -330,7 +330,7 @@ export async function downloadBulkIDCards({ employees, baseUrl, onStateChange, o if (blobData) { validatedPhotoUrl = URL.createObjectURL(blobData); if (onProcessEmployeePhoto) { - await onProcessEmployeePhoto(strictCleanId, blobData); + await onProcessEmployeePhoto(strictCleanId, blobData, emp.selectedClient || ''); } } } catch (err) { diff --git a/src/services/storage.js b/src/services/storage.js index d6e59c3..15e455f 100644 --- a/src/services/storage.js +++ b/src/services/storage.js @@ -1,21 +1,59 @@ import { supabase } from "./supabase"; -export async function uploadEmployeePhoto(employeeNumber, file) { +/** + * Sube la foto de un empleado al bucket "foto_empleados". + * + * El nombre del archivo sigue el patrón: + * _.jpg — primera foto + * _1.jpg — segunda foto del mismo empleado+cliente + * _2.jpg — tercera, etc. + * + * @param {string} employeeNumber - Número de cédula limpio (sin guiones/espacios) + * @param {File} file - Archivo de imagen a subir + * @param {string} [clientKey=''] - Nombre del cliente/proyecto (p.ej. "claro", "nestle") + * @returns {Promise} - URL pública del archivo subido + */ +export async function uploadEmployeePhoto(employeeNumber, file, clientKey = '') { + const extension = file.name.split('.').pop() || 'jpg'; - const extension = file.name.split(".").pop(); + // Normalizar el nombre del cliente: minúsculas, sin espacios ni caracteres especiales + const safeClient = clientKey + ? '_' + clientKey.toLowerCase().replace(/[^a-z0-9]/g, '') + : ''; - const fileName = `${employeeNumber}.${extension}`; + const baseName = `${employeeNumber}${safeClient}`; + + // Buscar archivos existentes con el mismo prefijo para elegir el sufijo correcto + const { data: existingFiles } = await supabase.storage + .from('foto_empleados') + .list('', { search: baseName }); + + // Filtrar exactamente los archivos que tengan el mismo baseName base + const pattern = new RegExp(`^${baseName}(\\d*)\\.${extension}$`); + const matches = (existingFiles || []).filter(f => pattern.test(f.name)); + + let fileName; + if (matches.length === 0) { + // No existe ninguno: usar el nombre base sin sufijo numérico + fileName = `${baseName}.${extension}`; + } else { + // Extraer los sufijos numéricos usados y elegir el siguiente + const usedSuffixes = matches.map(f => { + const m = f.name.match(pattern); + return m ? parseInt(m[1] || '0', 10) : 0; + }); + const nextSuffix = Math.max(...usedSuffixes) + 1; + fileName = `${baseName}${nextSuffix}.${extension}`; + } const { error } = await supabase.storage - .from("foto_empleados") - .upload(fileName, file, { - upsert: false - }); + .from('foto_empleados') + .upload(fileName, file, { upsert: false }); if (error) throw error; const { data } = supabase.storage - .from("foto_empleados") + .from('foto_empleados') .getPublicUrl(fileName); return data.publicUrl; From f2e914dbfa911a57939c489fd5b2ed801cf7554d Mon Sep 17 00:00:00 2001 From: EidanThen Date: Tue, 7 Jul 2026 12:24:30 -0400 Subject: [PATCH 6/9] feat: guardar conteo de carnet creados en Supabase --- src/Generator.jsx | 172 +++++++++++----------------------------- src/services/storage.js | 38 +++++++++ 2 files changed, 83 insertions(+), 127 deletions(-) diff --git a/src/Generator.jsx b/src/Generator.jsx index 7a1f14e..8fa73bf 100644 --- a/src/Generator.jsx +++ b/src/Generator.jsx @@ -1,33 +1,27 @@ import { useState, useRef, useEffect } from 'react'; import './Generator.css'; import { renderQRCode, downloadIDCards, downloadBulkIDCards, getXLSXLib, clientTemplates } from './script/script.jsx'; -import { uploadEmployeePhoto } from './services/storage.js'; +import { uploadEmployeePhoto, saveClienteProyecto } from './services/storage.js'; import { supabase } from "./services/supabase"; // Convierte datos binarios recibidos (base64, buffer, array de bytes) en un Blob URL local de forma robusta const convertBinaryToBlobUrl = (data) => { if (!data) return null; try { - // Si es un objeto tipo Buffer de Node.js/n8n (p. ej., { type: 'Buffer', data: [...] }) if (typeof data === 'object' && data.type === 'Buffer' && Array.isArray(data.data)) { const byteArray = new Uint8Array(data.data); const blob = new Blob([byteArray], { type: 'image/jpeg' }); return URL.createObjectURL(blob); } - - // Si ya es un array de números (bytes) + if (Array.isArray(data)) { const byteArray = new Uint8Array(data); const blob = new Blob([byteArray], { type: 'image/jpeg' }); return URL.createObjectURL(blob); } - // Si es un string if (typeof data === 'string') { - // Limpiar prefijo data URL si existe const cleanBase64 = data.replace(/^data:image\/\w+;base64,/, ""); - - // Decodificar Base64 const byteCharacters = atob(cleanBase64); const byteNumbers = new Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { @@ -38,7 +32,7 @@ const convertBinaryToBlobUrl = (data) => { return URL.createObjectURL(blob); } } catch (e) { - console.error("Error al convertir datos binarios a Blob URL:", e); + console.error("Error convirtiendo formato binario a Blob URL:", e); } return null; }; @@ -57,7 +51,7 @@ export default function IdCardGenerator() { // --- Estados Lote / Excel Masivo ─── const [bulkEmployees, setBulkEmployees] = useState([]); const [bulkStatusText, setBulkStatusText] = useState(''); - const [bulkDownloadStatus, setBulkDownloadStatus] = useState({ processing: false, text: '⬇ Descargar Carnets' }); + const [bulkDownloadStatus, setBulkDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' }); const [showTooltip, setShowTooltip] = useState(false); const fileInputRef = useRef(null); @@ -68,7 +62,6 @@ export default function IdCardGenerator() { ? import.meta.env.BASE_URL : `${import.meta.env.BASE_URL}/`; - // Al limpiar el input en tiempo real, employeeId ya vendrá totalmente limpio useEffect(() => { if (qrCanvasRef.current) { renderQRCode(employeeId, qrCanvasRef.current); @@ -83,15 +76,12 @@ export default function IdCardGenerator() { } }; - // Interceptor para bloquear en tiempo real guiones y espacios en blanco const handleEmployeeIdChange = (e) => { const rawValue = e.target.value; - // Reemplaza instantáneamente cualquier guión o espacio por vacío const cleanValue = rawValue.replace(/[-\s]/g, ''); setEmployeeId(cleanValue); }; - // --- Envío directo a n8n para recuperar las fotos procesadas --- const enviarDatosAn8n = async (datosEmpleados) => { const N8N_WEBHOOK_URL = import.meta.env.VITE_N8N_WEBHOOK_URL; const TOKEN_SECRETO = import.meta.env.VITE_WEBHOOK_TOKEN; @@ -111,69 +101,41 @@ export default function IdCardGenerator() { }); if (!response.ok) { - console.error('Error en n8n:', response.status); throw new Error(`El servidor de n8n rechazó la petición (Código: ${response.status})`); } - const rawData = await response.json(); - console.log('Respuesta cruda de n8n:', rawData); - - let dataArray = []; - if (Array.isArray(rawData)) { - dataArray = rawData; - } else if (rawData && typeof rawData === 'object') { - const keys = ['empleados', 'data', 'result', 'employees', 'items', 'output']; - for (const key of keys) { - if (Array.isArray(rawData[key])) { - dataArray = rawData[key]; - break; - } - } - if (dataArray.length === 0 && (rawData.name || rawData.employeeId || rawData.data || rawData.json)) { - dataArray = [rawData]; - } - } - - // Normalizar el envoltorio `{ json: ... }` si viene de n8n - return dataArray.map(item => { - if (item && item.json && typeof item.json === 'object') { - return item.json; - } - return item; - }); + const resData = await response.json(); + return Array.isArray(resData) ? resData : (resData.empleados || resData.data || []); } catch (error) { - console.error('Error de red enviando a n8n:', error); + console.error('Error de comunicación con n8n:', error); throw error; } }; - // --- ALMACENAMIENTO AUTOMÁTICO EN EL STORAGE E INSTANCIACIÓN RPC --- - const handleBulkSupabasePhotoSync = async (cedula, blobData, clientKey = '') => { + const handleBulkSupabaseSync = async (cedula, blobData, currentEmployee) => { const cleanCedula = String(cedula).replace(/[-\s]/g, ''); + if (!cleanCedula) return; + try { + // Se usa el cliente extraído directamente del array renderizado + const clienteProyectoValue = currentEmployee?.selectedClient || 'Generico'; + await saveClienteProyecto(cleanCedula, clienteProyectoValue); + console.log(`[Supabase DB] Guardado: ${cleanCedula} → ${clienteProyectoValue}`); + const customFile = new File([blobData], `${cleanCedula}.jpg`, { type: 'image/jpeg' }); - const remoteStorageUrl = await uploadEmployeePhoto(cleanCedula, customFile, clientKey); + const remoteStorageUrl = await uploadEmployeePhoto(cleanCedula, customFile); if (remoteStorageUrl) { - const { data, error } = await supabase.rpc("save_employee_photo", { + await supabase.rpc("save_employee_photo", { p_employee_number: cleanCedula, p_photo_url: remoteStorageUrl }); - - if (error) { - console.warn(`[Supabase RPC Error] Cédula ${cleanCedula}:`, error.message); - } else if (data && data.success === false) { - console.log(`[Supabase Info] Cédula ${cleanCedula}: ${data.message}`); - } else { - console.log(`[Supabase Success] Foto sincronizada para la Cédula: ${cleanCedula}`); - } } } catch (error) { - console.error(`Sincronización abortada para la cédula ${cleanCedula}:`, error); + console.error(`[Supabase Sync Fallido] Cédula ${cleanCedula}:`, error); } }; - // --- MAPEO DE COLUMNAS EXCEL --- const handleExcelUpload = async (e) => { const file = e.target.files[0]; if (!file) return; @@ -237,7 +199,7 @@ export default function IdCardGenerator() { setBulkEmployees(formatted); setBulkStatusText(`✅ ¡Cargados ${formatted.length} colaboradores! Listo para procesar.`); - setBulkDownloadStatus({ processing: false, text: '⬇ Descargar Carnets' }); + setBulkDownloadStatus({ processing: false, text: '⬇ Descargar' }); } catch (innerErr) { console.error(innerErr); @@ -251,7 +213,6 @@ export default function IdCardGenerator() { } }; - // --- ACCIÓN DEL BOTÓN CORREGIDA CON FINALLY Y MAPEO TOTAL DE VARIABLES --- const triggerBulkDownload = async () => { if (bulkEmployees.length === 0) return; @@ -259,102 +220,58 @@ export default function IdCardGenerator() { setBulkStatusText('Conectando con n8n y descargando retratos optimizados por IA...'); try { - // 1. Obtenemos las imágenes crudas en Base64/Binario desde el webhook de n8n const resultadosN8n = await enviarDatosAn8n(bulkEmployees); - - if (!resultadosN8n || resultadosN8n.length === 0) { - throw new Error('No se recibieron colaboradores procesados desde n8n.'); - } - setBulkStatusText('Generando frentes, reversos y empaquetando en carpetas locales...'); - // 2. Mapeamos garantizando que TODAS las propiedades originales existan para script.jsx - const employeesReadyForRender = resultadosN8n.map((n8nEmp, index) => { - // Buscamos la fila correspondiente en el Excel original (por employeeId o nombre) para mantener cliente - const n8nId = String(n8nEmp.employeeId || n8nEmp.employee_id || n8nEmp.cedula || n8nEmp.id || '').replace(/[-\s]/g, ''); - const n8nName = String(n8nEmp.name || n8nEmp.nombre || '').trim().toLowerCase(); + const employeesReadyForRender = bulkEmployees.map((originalEmp, index) => { + const n8nEmp = resultadosN8n[index] || resultadosN8n.find(e => e.employeeId === originalEmp.employeeId) || {}; - const originalEmp = bulkEmployees.find(e => { - const origId = String(e.employeeId).replace(/[-\s]/g, ''); - const origName = String(e.name).trim().toLowerCase(); - return (n8nId && origId === n8nId) || (n8nName && origName === n8nName); - }) || bulkEmployees[index] || {}; + const base64Source = n8nEmp.data || n8nEmp.foto_processed_base64 || originalEmp.data; + let finalPhotoUrl = convertBinaryToBlobUrl(base64Source) || originalEmp.fotoUrl; - // Mapeo/limpieza de cliente/proyecto - let rawClient = n8nEmp.selectedClient || n8nEmp.client || n8nEmp.proyecto || n8nEmp['cliente/proyecto'] || originalEmp.selectedClient || 'Generico'; - const lowerClient = String(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'; + const validClient = n8nEmp.selectedClient || originalEmp.selectedClient || 'Generico'; + const validLang = n8nEmp.language || originalEmp.language || 'Esp'; - // Mapeo/conversión de la imagen del empleado - let localPhotoUrl = originalEmp.fotoUrl || ''; - const imageData = n8nEmp.data || n8nEmp.foto_processed_base64 || n8nEmp.binary || n8nEmp.image || originalEmp.data; - - if (imageData) { - const blobUrl = convertBinaryToBlobUrl(imageData); - if (blobUrl) { - localPhotoUrl = blobUrl; - } - } - - // Normalización de idioma - let rawLang = String(n8nEmp.language || n8nEmp.lenguaje || n8nEmp.idioma || originalEmp.language || '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'; - } - - // Inyectamos las claves exactas que script.jsx desestructura return { - name: String(n8nEmp.name || n8nEmp.nombre || originalEmp.name || '').trim(), - role: String(n8nEmp.role || n8nEmp.puesto || originalEmp.role || '').trim(), - selectedClient: rawClient, - language: rawLang, - employeeId: n8nId || String(originalEmp.employeeId || '').replace(/[-\s]/g, ''), - fotoUrl: localPhotoUrl + name: n8nEmp.name || originalEmp.name, + role: n8nEmp.role || originalEmp.role, + selectedClient: validClient, + language: validLang, + employeeId: n8nEmp.employeeId || originalEmp.employeeId, + fotoUrl: finalPhotoUrl }; - }).filter(emp => emp.name && emp.employeeId); + }); - if (employeesReadyForRender.length === 0) { - throw new Error('Ningún colaborador posee datos válidos de nombre e identificación para procesar.'); - } - - // 3. Ejecutamos tu motor original en script.jsx que procesará los diseños de Claro, Nestle, etc. await downloadBulkIDCards({ employees: employeesReadyForRender, baseUrl, - onStateChange: null, // Evitamos sobreescritura conflictiva de estados dentro del script - onProcessEmployeePhoto: handleBulkSupabasePhotoSync + onStateChange: null, + // CORRECCIÓN AQUÍ: Buscamos al empleado en nuestro arreglo usando la cédula que nos devuelve script.jsx + onProcessEmployeePhoto: (cedula, blobData) => { + const currentEmp = employeesReadyForRender.find(emp => emp.employeeId === cedula); + return handleBulkSupabaseSync(cedula, blobData, currentEmp); + } }); - setBulkStatusText('🎉 ¡Lote de carnets guardado correctamente por carpetas!'); + setBulkStatusText('🎉 ¡Lote de carnets guardado correctamente y registrado en Supabase!'); setBulkEmployees([]); } catch (error) { console.error(error); setBulkStatusText(`❌ Error al procesar: ${error.message || error}`); } finally { - // El bloque finally se ejecuta SIEMPRE (tenga éxito o falle), liberando el botón de forma segura - setBulkDownloadStatus({ processing: false, text: '⬇ Descargar Carnets' }); + setBulkDownloadStatus({ processing: false, text: '⬇ Descargar' }); } }; const triggerDownload = async () => { if (!validateForm()) return; + setDownloadStatus({ processing: true, text: '🔄 Descargando...' }); try { + await saveClienteProyecto(employeeId, selectedClient); + if (photoFile) { - const photoUrl = await uploadEmployeePhoto(employeeId, photoFile, selectedClient); + const photoUrl = await uploadEmployeePhoto(employeeId, photoFile); await supabase.rpc("save_employee_photo", { p_employee_number: employeeId, p_photo_url: photoUrl @@ -370,6 +287,7 @@ export default function IdCardGenerator() { } catch (err) { console.error(err); alert(err.message); + setDownloadStatus({ processing: false, text: '⬇ Descargar' }); } }; diff --git a/src/services/storage.js b/src/services/storage.js index 15e455f..310ea54 100644 --- a/src/services/storage.js +++ b/src/services/storage.js @@ -1,5 +1,43 @@ import { supabase } from "./supabase"; +/** + * Registra el cliente/proyecto de un carnet descargado en la tabla + * "carnet_empleados_creados_glm". + * + * @param {string} employeeNumber - Número de cédula limpio (sin guiones/espacios) + * @param {string} clienteProyecto - Nombre del cliente/proyecto seleccionado + * @returns {Promise} + */ +export async function saveClienteProyecto(employeeNumber, clienteProyecto) { + const cleanCedula = String(employeeNumber || '').replace(/[-\s]/g, ''); + const valorCliente = clienteProyecto || 'Generico'; + + console.log(`[Supabase] → saveClienteProyecto llamada con: cedula="${cleanCedula}", cliente_proyecto="${valorCliente}"`); + + if (!cleanCedula) { + console.warn('[Supabase] saveClienteProyecto abortada: cédula vacía.'); + return; + } + + // CORRECCIÓN: El objeto debe llevar la cédula para que upsert funcione por conflicto + const { data, error } = await supabase + .from('carnet_empleados_creados_glm') + .upsert( + { + cedula: cleanCedula, + cliente_proyecto: valorCliente + }, + { onConflict: 'cedula' } + ) + .select(); + + if (error) { + console.error(`[Supabase] ❌ Error guardando cliente_proyecto para cédula ${cleanCedula}:`, error); + } else { + console.log(`[Supabase] ✅ Guardado exitoso carnet_empleados_creados_glm:`, data); + } +} + /** * Sube la foto de un empleado al bucket "foto_empleados". * From dfa0d63061dc3f6823d2918858d1daffc877ce32 Mon Sep 17 00:00:00 2001 From: EidanThen Date: Tue, 7 Jul 2026 16:55:11 -0400 Subject: [PATCH 7/9] feat: implementacion de flujo n8n en el formulario principal --- src/Generator.css | 2 +- src/Generator.jsx | 86 ++++++++++++++++++++++++++++++++++++++----- src/script/script.jsx | 4 +- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/Generator.css b/src/Generator.css index d99dd91..dc2125d 100644 --- a/src/Generator.css +++ b/src/Generator.css @@ -273,7 +273,7 @@ body { width: 100%; height: 100%; object-fit: cover; - display: none; + display: block; } .front-photo-frame .placeholder-svg { diff --git a/src/Generator.jsx b/src/Generator.jsx index 8fa73bf..59e805c 100644 --- a/src/Generator.jsx +++ b/src/Generator.jsx @@ -46,8 +46,12 @@ export default function IdCardGenerator() { const [employeeId, setEmployeeId] = useState(''); const [photoSrc, setPhotoSrc] = useState(''); const [photoFile, setPhotoFile] = useState(null); + const [photoProcessing, setPhotoProcessing] = useState(false); const [downloadStatus, setDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' }); + // Bloquea el botón Descargar hasta que nombre, puesto e ID estén llenos + const isFormComplete = name.trim() && role.trim() && employeeId.trim(); + // --- Estados Lote / Excel Masivo ─── const [bulkEmployees, setBulkEmployees] = useState([]); const [bulkStatusText, setBulkStatusText] = useState(''); @@ -68,14 +72,65 @@ export default function IdCardGenerator() { } }, [employeeId]); - const handlePhotoUpload = (e) => { + const handlePhotoUpload = async (e) => { const file = e.target.files[0]; - if (file) { - setPhotoFile(file); - setPhotoSrc(URL.createObjectURL(file)); + if (!file) return; + + setPhotoFile(file); + setPhotoSrc(URL.createObjectURL(file)); + setPhotoProcessing(true); + + try { + const editedBlob = await processPhotoWithAI(file); + const editedUrl = URL.createObjectURL(editedBlob); + setPhotoSrc(editedUrl); + setPhotoFile(new File([editedBlob], file.name, { type: editedBlob.type || 'image/jpeg' })); + } catch (error) { + console.error('Error al procesar la foto con IA en n8n:', error); + alert('No se pudo procesar la foto con IA. Se usará la foto original para la vista previa.'); + } finally { + setPhotoProcessing(false); } }; + const processPhotoWithAI = async (file) => { + const N8N_WEBHOOK_URL = import.meta.env.VITE_N8N_WEBHOOK_URL; + const TOKEN_SECRETO = import.meta.env.VITE_WEBHOOK_TOKEN; + + const formData = new FormData(); + formData.append('edited', file, file.name); + formData.append('name', name); + formData.append('role', role); + formData.append('employeeId', employeeId); + formData.append('fechaProcesado', new Date().toISOString()); + + const response = await fetch(N8N_WEBHOOK_URL, { + method: 'POST', + headers: { + 'Authorization': TOKEN_SECRETO + }, + body: formData + }); + + if (!response.ok) { + throw new Error(`El servidor de n8n rechazó la petición (Código: ${response.status})`); + } + + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/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 blobUrl = convertBinaryToBlobUrl(bin); + if (!blobUrl) throw new Error('n8n no devolvió un binario válido.'); + const blob = await (await fetch(blobUrl)).blob(); + return blob; + } + + const blob = await response.blob(); + if (!blob || blob.size === 0) throw new Error('n8n devolvió un binario vacío.'); + return blob; + }; + const handleEmployeeIdChange = (e) => { const rawValue = e.target.value; const cleanValue = rawValue.replace(/[-\s]/g, ''); @@ -217,7 +272,7 @@ export default function IdCardGenerator() { if (bulkEmployees.length === 0) return; setBulkDownloadStatus({ processing: true, text: '🔄 Descargando...' }); - setBulkStatusText('Conectando con n8n y descargando retratos optimizados por IA...'); + setBulkStatusText('Descargando retratos...'); try { const resultadosN8n = await enviarDatosAn8n(bulkEmployees); @@ -253,7 +308,7 @@ export default function IdCardGenerator() { } }); - setBulkStatusText('🎉 ¡Lote de carnets guardado correctamente y registrado en Supabase!'); + setBulkStatusText('🎉 ¡Lote de carnets guardado correctamente!'); setBulkEmployees([]); } catch (error) { @@ -394,8 +449,8 @@ export default function IdCardGenerator() { setRole(e.target.value)} />
-
fileInputRef.current.click()}> - Subir foto del colaborador +
!photoProcessing && fileInputRef.current.click()} style={{ opacity: photoProcessing ? 0.6 : 1, cursor: photoProcessing ? 'wait' : 'pointer' }}> + {photoProcessing ? '⏳ Procesando foto con IA...' : 'Subir foto del colaborador'}
@@ -447,7 +502,7 @@ export default function IdCardGenerator() {
Exportar
-
@@ -461,7 +516,18 @@ export default function IdCardGenerator() {
{photoSrc ? ( Foto Colaborador - ) : ( + ) : (null)} + {photoProcessing && ( +
+ ⏳ Editando foto con IA... +
+ )} + {!photoSrc && ( diff --git a/src/script/script.jsx b/src/script/script.jsx index 112416a..1b85a01 100644 --- a/src/script/script.jsx +++ b/src/script/script.jsx @@ -221,7 +221,7 @@ export async function downloadIDCards(config) { `; if (photoSrc) { - photoTagHtml = ``; + photoTagHtml = `
`; } vFront.innerHTML = ` @@ -356,7 +356,7 @@ export async function downloadBulkIDCards({ employees, baseUrl, onStateChange, o `; if (validatedPhotoUrl) { - photoTagHtml = ``; + photoTagHtml = `
`; } vFront.innerHTML = ` From dc2fa3e681e8e8e5b33ca8a58600f0e7dd5d3f47 Mon Sep 17 00:00:00 2001 From: EidanThen Date: Fri, 10 Jul 2026 11:58:52 -0400 Subject: [PATCH 8/9] feat: update base path, and add image processing utility scripts --- src/App.jsx | 4 ++-- src/script/script.jsx | 2 +- vite.config.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 7783bc6..3b9e6ff 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,12 +11,12 @@ export default function App() { } /> } /> diff --git a/src/script/script.jsx b/src/script/script.jsx index 1b85a01..5c478cd 100644 --- a/src/script/script.jsx +++ b/src/script/script.jsx @@ -132,7 +132,7 @@ export async function renderQRCode(employeeId, canvasElement) { document.body.appendChild(tmp); new QRCodeLib(tmp, { - text: `http://localhost:5173/employee/${cleanId}`, + text: `http://localhost:5173/empleado-id/${cleanId}`, width: 55, height: 55, colorDark: '#000000', diff --git a/vite.config.js b/vite.config.js index 02c3da0..e85d4b1 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,5 +4,5 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], - base: '/employee' + base: '/empleado-id/' }) From 95312c9621fbe36c465e53b1129f534f6a6a9a7d Mon Sep 17 00:00:00 2001 From: EidanThen Date: Fri, 10 Jul 2026 17:46:15 -0400 Subject: [PATCH 9/9] feat: implement Supabase OAuth authentication with domain-restricted login and protected routes --- src/App.jsx | 189 ++++++++++++++++++++++++++++++++++----- src/components/Login.css | 163 +++++++++++++++++++++++++++++++++ src/components/Login.jsx | 97 ++++++++++++++++++++ src/services/supabase.js | 5 +- 4 files changed, 431 insertions(+), 23 deletions(-) create mode 100644 src/components/Login.css create mode 100644 src/components/Login.jsx diff --git a/src/App.jsx b/src/App.jsx index 3b9e6ff..41c1184 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,29 +1,176 @@ -import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom"; +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"; -export default function App() { +const AuthContext = createContext(null); + +export const useAuth = () => useContext(AuthContext); + +const ALLOWED_DOMAIN = "@gomezleemarketing.com"; +const GLM_LOGO = "https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png"; + +function AuthProvider({ children }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!supabase) { + console.warn("Supabase no configurado - falta VITE_SUPABASE_ANON_KEY"); + setLoading(false); + return; + } + + supabase.auth.getSession().then(({ data: { session } }) => { + setUser(session?.user ?? null); + setLoading(false); + }); + + const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { + setUser(session?.user ?? null); + setLoading(false); + }); + + return () => subscription.unsubscribe(); + }, []); + + const signOut = async () => { + if (supabase) await supabase.auth.signOut(); + }; + + if (loading) { + return ( +
+
+
+ ); + } return ( - - - - - - } - /> - - } - /> - - - - - + + {children} + ); +} +function ProtectedRoute({ children }) { + const { user, signOut, loading } = useAuth(); + const location = useLocation(); + + if (loading) return null; + + if (!user) { + return ; + } + + if (!user.email?.endsWith(ALLOWED_DOMAIN)) { + signOut(); + return ( +
+ GLM +

+ Acceso no autorizado +

+

+ Solo se permite acceso con cuentas corporativas @gomezleemarketing.com. + Tu cuenta {user.email} no tiene permisos. +

+ +
+ ); + } + + return children; +} + +function PublicRoute({ children }) { + const { user, loading } = useAuth(); + const location = useLocation(); + + if (loading) return null; + + if (user?.email?.endsWith(ALLOWED_DOMAIN)) { + const from = location.state?.from?.pathname || '/empleado-id/'; + return ; + } + + return children; +} + +export default function App() { + return ( + + + + + + + } + /> + + + + + } + /> + + + + + } + /> + + } /> + + + + ); } \ No newline at end of file diff --git a/src/components/Login.css b/src/components/Login.css new file mode 100644 index 0000000..9a89d74 --- /dev/null +++ b/src/components/Login.css @@ -0,0 +1,163 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --azul-glm: #4F758B; + --azul-medio: #5B7F95; + --acero-claro: #6B8FA3; + --verde-glm: #6CC24A; + --verde-claro: #A4D65E; + --verde-bg: #EEF6E8; + --azul-seccion: #D6E8F4; + --gris-oscuro: #4A4A4A; + --gris-medio: #D0D0D0; + --gris-claro: #F5F5F5; + --blanco: #FFFFFF; + --naranja: #FF6A13; +} + +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: linear-gradient(135deg, var(--blanco) 0%, var(--gris-claro) 100%); + font-family: Arial, Helvetica, sans-serif; +} + +.login-page.loading .login-card { + pointer-events: none; + opacity: 0.8; +} + +.login-card { + background: var(--blanco); + border-radius: 16px; + box-shadow: 0 8px 32px rgba(79, 117, 139, 0.12); + padding: 48px 40px; + width: 100%; + max-width: 400px; + text-align: center; + border: 1px solid var(--gris-medio); +} + +.logo { + width: 180px; + height: auto; + margin-bottom: 24px; +} + +.title { + font-size: 22px; + font-weight: 700; + color: var(--azul-glm); + letter-spacing: 0.5px; + margin-bottom: 6px; + text-transform: uppercase; +} + +.subtitle { + font-size: 13px; + color: var(--acero-claro); + font-weight: 400; + margin-bottom: 24px; +} + +.login-divider { + height: 2px; + background: var(--verde-glm); + margin: 24px 0; + border-radius: 1px; +} + +.btn-google { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 12px; + width: 100%; + padding: 14px 24px; + background: var(--blanco); + border: 2px solid var(--gris-medio); + border-radius: 10px; + font-family: Arial, Helvetica, sans-serif; + font-size: 15px; + font-weight: 600; + color: var(--gris-oscuro); + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.btn-google:hover:not(:disabled) { + border-color: var(--verde-glm); + background: var(--verde-bg); + color: var(--azul-glm); + box-shadow: 0 4px 16px rgba(108, 194, 74, 0.2); + transform: translateY(-1px); +} + +.btn-google:active:not(:disabled) { + transform: translateY(0); +} + +.btn-google:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.google-icon { + flex-shrink: 0; +} + +.domain-hint { + margin-top: 14px; + font-size: 12px; + color: var(--acero-claro); + font-weight: 500; +} + +.error-message { + margin-top: 16px; + padding: 12px 16px; + background: #FDECEA; + border: 1px solid #F5C6CB; + border-radius: 8px; + font-size: 13px; + color: #C0392B; + text-align: left; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid var(--gris-medio); + border-top-color: var(--verde-glm); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 16px auto 0; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 480px) { + .login-card { + padding: 36px 24px; + } + + .title { + font-size: 19px; + } + + .logo { + width: 150px; + } +} \ No newline at end of file diff --git a/src/components/Login.jsx b/src/components/Login.jsx new file mode 100644 index 0000000..47057c5 --- /dev/null +++ b/src/components/Login.jsx @@ -0,0 +1,97 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { supabase } from '../services/supabase'; +import './Login.css'; + +const ALLOWED_DOMAIN = '@gomezleemarketing.com'; +const GLM_LOGO = 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png'; + +export default function Login() { + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + supabase.auth.getSession().then(({ data: { session } }) => { + if (session?.user?.email?.endsWith(ALLOWED_DOMAIN)) { + navigate('/empleado-id/'); + } + }); + }, [navigate]); + + const handleGoogleLogin = async () => { + setLoading(true); + setError(''); + + const redirectUrl = `${window.location.origin}/empleado-id/`; + + const { error: authError } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo: redirectUrl, + queryParams: { + hd: 'gomezleemarketing.com', + }, + }, + }); + + if (authError) { + setError(authError.message); + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+ GLM +
+
+
+ ); + } + + return ( +
+
+ GomezLee Marketing + +

GLM ID Card Generator

+

GomezLee Marketing · Carnet Corporativo CR80

+ +
+ + + +

Solo cuentas @gomezleemarketing.com

+ + {error &&
{error}
} +
+
+ ); +} \ No newline at end of file diff --git a/src/services/supabase.js b/src/services/supabase.js index 7380337..2ea490e 100644 --- a/src/services/supabase.js +++ b/src/services/supabase.js @@ -1,6 +1,7 @@ import { createClient } from "@supabase/supabase-js"; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; -const supabaseKey = import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; -export const supabase = createClient(supabaseUrl, supabaseKey); \ No newline at end of file +// Single client for auth (uses anon key) +export const supabase = createClient(supabaseUrl, supabaseAnonKey); \ No newline at end of file