From cb0636c9577282ba0b81c12bb45dee862f55dd5f Mon Sep 17 00:00:00 2001 From: EidanThen Date: Mon, 6 Jul 2026 22:05:31 -0400 Subject: [PATCH 1/2] 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; -- 2.52.0 From f2e914dbfa911a57939c489fd5b2ed801cf7554d Mon Sep 17 00:00:00 2001 From: EidanThen Date: Tue, 7 Jul 2026 12:24:30 -0400 Subject: [PATCH 2/2] 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". * -- 2.52.0